Form Validation UX und minimalistisches Dashboard Redesign
Form Validation UX und minimalistisches Dashboard Redesign
Heute habe ich zwei zusammenhängende UX-Verbesserungen an BudgetBuddy vorgenommen: Ein durchgängiges Formular-Validierungs-System und ein radikal vereinfachtes Dashboard. Beide Änderungen verfolgen dasselbe Ziel – dem Benutzer genau die Information zu geben, die er braucht, und nichts mehr.
Ausgangslage
BudgetBuddy hatte zwei UX-Probleme, die auf den ersten Blick unabhängig voneinander aussahen:
Problem 1: Unsichtbare Formularvalidierung
Wenn ein Benutzer auf einen “Speichern”-Button klickte, der deaktiviert war, passierte… nichts. Der Button sah fast genauso aus wie ein aktiver Button (nur minimal ausgegraut), und es gab keinerlei Feedback darüber, warum der Button nicht funktionierte. War etwas kaputt? Fehlte ein Feld? Welches Feld?
Problem 2: Dashboard-Überladung
Das Dashboard zeigte drei Statistik-Karten (“Letzter Sync”, “Total Importiert”, “Sync Sessions”) und eine Historie der letzten fünf Syncs. Das klingt nach nützlichen Informationen – war es aber nicht. Keine dieser Zahlen war anklickbar oder führte irgendwohin. “42 Transaktionen importiert” ist eine Zahl ohne Kontext. Die Historie konnte man nicht anklicken, um Details zu sehen.
Benutzer-Feedback war eindeutig: “Ich schaue mir das Dashboard nie an. Ich klicke direkt auf ‘Sync starten’.”
Herausforderung 1: Validierungsfeedback ohne Redundanz
Das Problem
Ein typisches Formular in BudgetBuddy (z.B. die Comdirect-Einstellungen) hat mehrere Pflichtfelder:
- Client ID
- Client Secret
- Benutzerkennung
- PIN
- Account-ID
Wenn eines fehlt, sollte der Speichern-Button deaktiviert sein. Aber welches fehlt?
Die naive Lösung wäre, unter jedem Feld eine Fehlermeldung anzuzeigen (“Dieses Feld ist erforderlich”). Das führt aber zu visueller Überfrachtung – fünf rote Fehlermeldungen sehen aus wie ein Katastrophen-Bildschirm.
Optionen, die ich betrachtet habe
Option 1: Fehlermeldung pro Feld
// Jedes Feld zeigt seinen eigenen Fehler
Input.group {
Label = "Client ID"
Error = if String.IsNullOrEmpty model.ClientId then Some "Erforderlich" else None
...
}
Pro:
- Direkte Zuordnung von Fehler zu Feld
- Bekanntes Pattern aus Web-Formularen
Contra:
- Bei 5 leeren Feldern = 5 Fehlermeldungen = visuelles Chaos
- Benutzer wird erschlagen, bevor er überhaupt angefangen hat
- Redundant: Der Benutzer sieht, dass das Feld leer ist
Option 2: Toast-Benachrichtigung beim Klick
// Beim Klick auf deaktivierten Button erscheint Toast
| SaveClicked when not isValid ->
model, Cmd.none, ShowToast "Bitte füllen Sie alle Pflichtfelder aus"
Pro:
- Keine permanente visuelle Störung
- Klares Signal, dass etwas fehlt
Contra:
- Sagt nicht, welche Felder fehlen
- Button ist deaktiviert, also kommt der Klick nicht an
- Toast verschwindet, Information ist weg
Option 3: Zusammengefasste Meldung unter dem Button (gewählt)
// Eine Meldung listet alle fehlenden Felder auf
Html.div [
prop.text $"Bitte ausfüllen: {missingFields}"
]
Pro:
- Eine zentrale Stelle für Validierungsfeedback
- Listet konkret die fehlenden Felder auf
- Verschwindet automatisch, wenn alles ausgefüllt ist
- Fokussiert auf den Ort, wo der Benutzer hinschauen will (den Button)
Contra:
- Benutzer muss zum Button scrollen, um den Fehler zu sehen
- Bei sehr vielen Feldern wird die Liste lang
Ich habe mich für Option 3 entschieden, weil BudgetBuddy-Formulare selten mehr als 5-6 Pflichtfelder haben und der Button typischerweise sichtbar ist.
Die Lösung: Form.fs Design System Component
Ich habe ein neues Modul Form.fs im Design System erstellt:
module Client.DesignSystem.Form
open Feliz
open Client.DesignSystem.Button
open Client.DesignSystem.Icons
open Client.DesignSystem.Loading
// Prüft, ob ein Wert als "ausgefüllt" gilt
let private isRequiredValid (value: string) =
not (System.String.IsNullOrWhiteSpace value)
// Gibt die Namen aller fehlenden Felder zurück
let private getMissingFields (fields: (string * string) list) =
fields
|> List.filter (fun (_, value) -> not (isRequiredValid value))
|> List.map fst
let submitButton (text: string) (onClick: unit -> unit) (isLoading: bool) (requiredFields: (string * string) list) =
let missingFields = getMissingFields requiredFields
let isDisabled = isLoading || not (List.isEmpty missingFields)
Html.div [
prop.className "space-y-2"
prop.children [
Button.view {
Button.defaultProps with
Text = text
OnClick = onClick
Variant = Button.Primary
IsLoading = isLoading
IsDisabled = isDisabled
Icon = Some (Icons.check SM Icons.Primary)
}
// Validierungsmeldung nur wenn deaktiviert UND Felder fehlen
if isDisabled && not isLoading && not (List.isEmpty missingFields) then
Html.div [
prop.className "flex items-center gap-2 text-sm text-neon-orange"
prop.children [
Icons.warning SM NeonOrange
Html.span [
let fields = String.concat ", " missingFields
prop.text $"Bitte ausfüllen: {fields}"
]
]
]
]
]
Architekturentscheidung: Warum eine separate Komponente?
- Konsistenz: Alle Formulare in der App verwenden jetzt dasselbe Pattern
- Deklarativ: Der Aufrufer übergibt nur eine Liste von
(Feldname, Wert)– die Logik steckt in der Komponente - Testbar: Die Validierungslogik ist pure und leicht zu testen
- Erweiterbar: Später können wir weitere Validierungsregeln hinzufügen (Min-Länge, Pattern, etc.)
Button-Styling für Disabled-State
Ein weiteres Problem war, dass deaktivierte Buttons kaum von aktiven zu unterscheiden waren. Ich habe in Button.fs den disabled-State angepasst:
// Vorher: Nur cursor-not-allowed
"disabled:cursor-not-allowed"
// Nachher: Deutlich sichtbar deaktiviert
"disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
Die opacity-50 macht den Button halbtransparent, und shadow-none entfernt den charakteristischen Neon-Glow-Effekt. Jetzt ist sofort erkennbar, dass der Button nicht klickbar ist.
Required-Marker konsistent machen
Das letzte Puzzleteil war, alle Pflichtfelder mit einem roten Sternchen zu markieren. Das Input.group-Pattern hatte bereits Required: bool, aber es wurde nicht überall genutzt. Ich habe eine Convenience-Funktion hinzugefügt:
let groupRequired label children =
group {
Label = label
Required = true
Error = None
HelpText = None
Children = children
}
Das Label-Rendering zeigt jetzt automatisch das Sternchen:
Html.label [
prop.children [
Html.text props.Label
if props.Required then
Html.span [
prop.className "text-neon-red ml-0.5"
prop.text "*"
]
]
]
Herausforderung 2: Das Dashboard-Dilemma
Das Problem
Das Dashboard war ein klassischer Fall von “Feature Creep”. Beim initialen Design dachte ich: “Ein Dashboard sollte Statistiken zeigen!” Also habe ich hinzugefügt:
- Letzter Sync: Datum und Uhrzeit des letzten Syncs
- Total Importiert: Gesamtzahl aller jemals importierten Transaktionen
- Sync Sessions: Anzahl der durchgeführten Syncs
- Quick Actions: Buttons für häufige Aktionen
- Recent Activity: Die letzten 5 Sync-Sessions mit Zeitstempel
Das Problem: Keine dieser Informationen half dem Benutzer, irgendetwas zu tun. “23 Sync Sessions” – und dann? Was macht der Benutzer mit dieser Information?
Die radikale Lösung: Alles löschen
Ich habe das gesamte Dashboard auf einen einzigen Button reduziert: “Start Sync”.
Vorher:
+------------------+------------------+------------------+
| Letzter Sync | Total Importiert | Sync Sessions |
| 15.12.25 18:30 | 1,234 | 23 |
+------------------+------------------+------------------+
| Quick Actions |
| [Start Sync] [Rules] [Settings] |
+------------------+------------------+------------------+
| Recent Activity |
| - 15.12.25 18:30: 12 Transaktionen |
| - 14.12.25 09:15: 8 Transaktionen |
| - 13.12.25 20:00: 15 Transaktionen |
| ... |
+--------------------------------------------------------+
Nachher:
[ Start Sync ]
Letzter Sync: 15.12.25 18:30
12 Transaktionen
Warum das besser ist
1. Eine Aktion, eine Frage
Wenn ein Benutzer BudgetBuddy öffnet, will er genau eine Sache: Transaktionen synchronisieren. Das Dashboard beantwortet jetzt nur noch zwei Fragen:
- “Was kann ich hier tun?” → Den großen Button drücken
- “Wann habe ich das zuletzt gemacht?” → Die Info unter dem Button
2. Kognitive Last reduziert
Das alte Dashboard hatte ~15 visuelle Elemente (3 Karten × 4 Elemente + 5 History-Items). Das neue hat 3: Button, Datum, Summary. Der Benutzer muss nicht mehr filtern, was wichtig ist.
3. Mobile-First
Auf einem Handy war das alte Dashboard ein Alptraum – scrollen, um den “Start Sync” Button zu finden. Jetzt ist er das Erste, was man sieht.
Implementierung: Drastisch vereinfachte Types
Die Dashboard-Types wurden entsprechend verschlankt:
// Vorher
type Model = {
CurrentSession: RemoteData<SyncSession option>
RecentSessions: RemoteData<SyncSession list> // Für Historie
Settings: RemoteData<Settings option>
Stats: RemoteData<DashboardStats> // Total Imported, etc.
}
// Nachher
type Model = {
CurrentSession: RemoteData<SyncSession option>
LastSession: RemoteData<SyncSession option> // Nur die letzte!
Settings: RemoteData<Settings option>
}
Die Messages wurden ebenfalls vereinfacht:
type Msg =
| LoadCurrentSession
| CurrentSessionLoaded of Result<SyncSession option, string>
| LoadLastSession // Vorher: LoadRecentSessions
| LastSessionLoaded of Result<SyncSession option, string> // Vorher: List
| LoadSettings
| SettingsLoaded of Result<Settings option, string>
Der Hero-Button
Der große “Start Sync” Button ist ein Custom-Styling, kein Standard-Design-System-Button:
let syncButton (onNavigateToSync: unit -> unit) =
Html.button [
prop.className """
group relative
px-12 py-5
rounded-xl
bg-gradient-to-r from-neon-orange to-neon-orange/80
text-base-100 font-bold text-lg md:text-xl font-display
shadow-[0_0_30px_rgba(255,107,44,0.4)]
hover:shadow-[0_0_50px_rgba(255,107,44,0.6)]
hover:scale-105
transition-all duration-300
"""
prop.onClick (fun _ -> onNavigateToSync())
prop.children [
Html.div [
prop.className "flex items-center gap-3"
prop.children [
Icons.sync Icons.MD Icons.Primary
Html.span [ prop.text "Start Sync" ]
]
]
]
]
Warum kein Design-System-Button?
Das Frontend Architecture Review hat dies als Verbesserungspotenzial markiert. Technisch korrekt – aber ich habe mich bewusst dagegen entschieden:
- Einmalige Verwendung: Dieser Button erscheint nur hier. Ein
Button.hero-Variant im Design System wäre Over-Engineering. - Spezielle Proportionen: px-12 py-5 sind deutlich größer als alle anderen Buttons (normalerweise px-4 py-2)
- Neon-Glow-Effekt: Der orangefarbene Schatten ist Dashboard-spezifisch und passt nicht zu anderen Kontexten
Falls ich später mehr “Hero”-Buttons brauche, werde ich das Design System erweitern. Bis dahin: YAGNI.
Lessons Learned
1. UX > Features
Es ist verlockend, mehr Features hinzuzufügen. “Ein Dashboard braucht Statistiken!” Aber jedes Feature, das nicht hilft, schadet – weil es Aufmerksamkeit von den Features abzieht, die helfen.
2. Validierungsfeedback gehört zum Action-Button
Die Idee, die Validierungsmeldung unter den Button zu setzen, kam aus der Beobachtung: Wenn ein Benutzer auf einen deaktivierten Button klickt, schaut er auf den Button. Genau dort sollte die Erklärung sein.
3. Konsistenz durch das Design System
Ohne das Design System hätte ich die Required-Marker in jedem Formular einzeln implementieren müssen. Mit dem Design System war es eine Änderung in Input.fs, und alle Formulare profitierten.
4. Weniger ist mehr (aber es braucht Mut)
Das Löschen von funktionierendem Code fühlt sich falsch an. “Das habe ich doch implementiert!” Aber wenn es dem Benutzer nicht hilft, ist es Ballast. Das Dashboard-Redesign hat ~200 Zeilen Code gelöscht und das Produkt besser gemacht.
Fazit
Zwei scheinbar unabhängige UX-Verbesserungen, die dasselbe Prinzip verfolgen: Dem Benutzer genau das zeigen, was er braucht – nicht mehr und nicht weniger.
Geänderte Dateien:
| Datei | Änderung |
|---|---|
src/Client/DesignSystem/Form.fs |
Neu: Validierungs-Component |
src/Client/DesignSystem/Button.fs |
Disabled-Styling verbessert |
src/Client/DesignSystem/Input.fs |
Required-Marker konsistent |
src/Client/Components/Dashboard/Types.fs |
Model vereinfacht |
src/Client/Components/Dashboard/State.fs |
LastSession statt RecentSessions |
src/Client/Components/Dashboard/View.fs |
Komplett neu: nur Button + Info |
src/Client/Components/Settings/View.fs |
Form.submitButton verwendet |
src/Client/Components/Rules/View.fs |
Form.submitButton verwendet |
src/Client/Components/SyncFlow/View.fs |
Form.submitButton verwendet |
Ergebnis:
- Build: ✅
- Tests: 294/294 bestanden
- Code gelöscht: ~200 Zeilen Dashboard-Komplexität
- Code hinzugefügt: ~50 Zeilen Form-Validierung
Key Takeaways für Neulinge
-
Validierungsfeedback ist kein Afterthought: Plane von Anfang an, wie du dem Benutzer Validierungsfehler zeigst. Eine konsistente Lösung im Design System spart später viel Arbeit.
-
Hinterfrage jeden Datenpunkt: Bevor du eine Statistik anzeigst, frage: “Was macht der Benutzer mit dieser Information?” Wenn die Antwort “nichts” ist, zeige sie nicht an.
-
Design System Components für wiederkehrende Patterns: Sobald du dasselbe Pattern zweimal schreibst, extrahiere es in eine wiederverwendbare Komponente. Das garantiert Konsistenz und macht Änderungen einfach.