Skip to content

Commit e2fc974

Browse files
Dinesh Reddyqwencoder
authored andcommitted
feat: replace browser default validation with custom error messages
Replace the browser's native 'Please fill in this field' validation tooltips with custom, design-cohesive inline error messages across all 14 forms in the dashboard. Changes: - Add noValidate to all forms to disable browser's native validation - Implement custom validation with inline error messages using existing design patterns (text-xs text-red-500 with role='alert') - Add aria-invalid and aria-describedby attributes for accessibility - Clear field errors on input change for better UX - Maintain all existing functionality without regressions Forms updated: - Login (email, password, SSO org slug) - Registration (name, email, password, org name) - Create project dialog (project name) - Create environment dialog (environment name) - Create segment (key, name) - Create flag (key, name, default value JSON) - API keys (key name) - Webhooks (name, URL) - Environment settings (environment name) - Team invite (email) - Target inspector (target key) - Target comparison (target A/B keys) - Onboarding project setup (project name) - Onboarding flag creation (flag key, flag name) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent c3e0896 commit e2fc974

File tree

13 files changed

+2185
-447
lines changed

13 files changed

+2185
-447
lines changed

dashboard/src/app/(app)/flags/page.tsx

Lines changed: 306 additions & 65 deletions
Large diffs are not rendered by default.

dashboard/src/app/(app)/onboarding/page.tsx

Lines changed: 331 additions & 90 deletions
Large diffs are not rendered by default.

dashboard/src/app/(app)/segments/page.tsx

Lines changed: 165 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import { api } from "@/lib/api";
55
import { useAppStore } from "@/stores/app-store";
66
import { SegmentRulesEditor } from "@/components/segment-rules-editor";
77
import { toast } from "@/components/toast";
8-
import { PageHeader, Card, Button, Input, Label, EmptyState } from "@/components/ui";
8+
import {
9+
PageHeader,
10+
Card,
11+
Button,
12+
Input,
13+
Label,
14+
EmptyState,
15+
} from "@/components/ui";
916
import { Select } from "@/components/ui/select";
1017
import { Users, Trash2, ChevronDown } from "lucide-react";
1118
import { ContextualHint, HINTS } from "@/components/contextual-hint";
@@ -23,19 +30,41 @@ export default function SegmentsPage() {
2330
const projectId = useAppStore((s) => s.currentProjectId);
2431
const [segments, setSegments] = useState<Segment[]>([]);
2532
const [showCreate, setShowCreate] = useState(false);
26-
const [form, setForm] = useState({ key: "", name: "", description: "", match_type: "all" });
33+
const [form, setForm] = useState({
34+
key: "",
35+
name: "",
36+
description: "",
37+
match_type: "all",
38+
});
39+
const [fieldErrors, setFieldErrors] = useState<{
40+
key?: string;
41+
name?: string;
42+
}>({});
2743
const [deleting, setDeleting] = useState<string | null>(null);
2844
const [expanded, setExpanded] = useState<string | null>(null);
2945

3046
function reload() {
3147
if (!token || !projectId) return;
32-
api.listSegments(token, projectId).then((s) => setSegments(s ?? [])).catch(() => {});
48+
api
49+
.listSegments(token, projectId)
50+
.then((s) => setSegments(s ?? []))
51+
.catch(() => {});
3352
}
3453

35-
useEffect(() => { reload(); }, [token, projectId]);
54+
useEffect(() => {
55+
reload();
56+
}, [token, projectId]);
3657

3758
async function handleCreate(e: React.FormEvent) {
3859
e.preventDefault();
60+
const errors: { key?: string; name?: string } = {};
61+
if (!form.key.trim()) errors.key = "Key is required";
62+
if (!form.name.trim()) errors.name = "Name is required";
63+
if (Object.keys(errors).length > 0) {
64+
setFieldErrors(errors);
65+
return;
66+
}
67+
setFieldErrors({});
3968
if (!token || !projectId) {
4069
toast("Select a project first", "error");
4170
return;
@@ -47,7 +76,10 @@ export default function SegmentsPage() {
4776
toast("Segment created", "success");
4877
reload();
4978
} catch (err: unknown) {
50-
toast(err instanceof Error ? err.message : "Failed to create segment", "error");
79+
toast(
80+
err instanceof Error ? err.message : "Failed to create segment",
81+
"error",
82+
);
5183
}
5284
}
5385

@@ -59,19 +91,32 @@ export default function SegmentsPage() {
5991
toast("Segment deleted", "success");
6092
reload();
6193
} catch (err: unknown) {
62-
toast(err instanceof Error ? err.message : "Failed to delete segment", "error");
94+
toast(
95+
err instanceof Error ? err.message : "Failed to delete segment",
96+
"error",
97+
);
6398
setDeleting(null);
6499
}
65100
}
66101

67-
async function handleSaveRules(segKey: string, rules: Condition[], matchType: string) {
102+
async function handleSaveRules(
103+
segKey: string,
104+
rules: Condition[],
105+
matchType: string,
106+
) {
68107
if (!token || !projectId) return;
69108
try {
70-
await api.updateSegment(token, projectId, segKey, { rules, match_type: matchType });
109+
await api.updateSegment(token, projectId, segKey, {
110+
rules,
111+
match_type: matchType,
112+
});
71113
toast("Segment rules saved", "success");
72114
reload();
73115
} catch (err: unknown) {
74-
toast(err instanceof Error ? err.message : "Failed to save rules", "error");
116+
toast(
117+
err instanceof Error ? err.message : "Failed to save rules",
118+
"error",
119+
);
75120
}
76121
}
77122

@@ -93,37 +138,98 @@ export default function SegmentsPage() {
93138
description="Reusable audience definitions for targeting"
94139
docsUrl={DOCS_LINKS.segments}
95140
actions={
96-
<Button onClick={() => setShowCreate(!showCreate)}>Create Segment</Button>
141+
<Button onClick={() => setShowCreate(!showCreate)}>
142+
Create Segment
143+
</Button>
97144
}
98145
/>
99146

100147
<ContextualHint hint={HINTS.segmentsIntro} />
101148

102149
{showCreate && (
103-
<form onSubmit={handleCreate} className="rounded-xl border border-slate-200/80 bg-white p-4 space-y-4 shadow-sm ring-1 ring-indigo-100 sm:p-6">
150+
<form
151+
onSubmit={handleCreate}
152+
noValidate
153+
className="rounded-xl border border-slate-200/80 bg-white p-4 space-y-4 shadow-sm ring-1 ring-indigo-100 sm:p-6"
154+
>
104155
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
105156
<div>
106157
<Label>Key</Label>
107-
<Input value={form.key} onChange={(e) => setForm({ ...form, key: e.target.value })} placeholder="beta-users" required className="mt-1" />
158+
<Input
159+
value={form.key}
160+
onChange={(e) => {
161+
setForm({ ...form, key: e.target.value });
162+
if (fieldErrors.key)
163+
setFieldErrors({ ...fieldErrors, key: undefined });
164+
}}
165+
placeholder="beta-users"
166+
required
167+
className="mt-1"
168+
aria-invalid={!!fieldErrors.key}
169+
aria-describedby={fieldErrors.key ? "key-error" : undefined}
170+
/>
171+
{fieldErrors.key && (
172+
<p className="text-xs text-red-500" role="alert" id="key-error">
173+
{fieldErrors.key}
174+
</p>
175+
)}
108176
</div>
109177
<div>
110178
<Label>Name</Label>
111-
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Beta Users" required className="mt-1" />
179+
<Input
180+
value={form.name}
181+
onChange={(e) => {
182+
setForm({ ...form, name: e.target.value });
183+
if (fieldErrors.name)
184+
setFieldErrors({ ...fieldErrors, name: undefined });
185+
}}
186+
placeholder="Beta Users"
187+
required
188+
className="mt-1"
189+
aria-invalid={!!fieldErrors.name}
190+
aria-describedby={fieldErrors.name ? "name-error" : undefined}
191+
/>
192+
{fieldErrors.name && (
193+
<p
194+
className="text-xs text-red-500"
195+
role="alert"
196+
id="name-error"
197+
>
198+
{fieldErrors.name}
199+
</p>
200+
)}
112201
</div>
113202
</div>
114203
<div>
115204
<Label>Description</Label>
116-
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Users enrolled in beta program" className="mt-1" />
205+
<Input
206+
value={form.description}
207+
onChange={(e) =>
208+
setForm({ ...form, description: e.target.value })
209+
}
210+
placeholder="Users enrolled in beta program"
211+
className="mt-1"
212+
/>
117213
</div>
118214
<div>
119215
<Label>Match Type</Label>
120216
<div className="mt-1">
121-
<Select value={form.match_type} onValueChange={(val) => setForm({ ...form, match_type: val })} options={MATCH_TYPE_OPTIONS} />
217+
<Select
218+
value={form.match_type}
219+
onValueChange={(val) => setForm({ ...form, match_type: val })}
220+
options={MATCH_TYPE_OPTIONS}
221+
/>
122222
</div>
123223
</div>
124224
<div className="flex gap-2">
125225
<Button type="submit">Create</Button>
126-
<Button type="button" variant="secondary" onClick={() => setShowCreate(false)}>Cancel</Button>
226+
<Button
227+
type="button"
228+
variant="secondary"
229+
onClick={() => setShowCreate(false)}
230+
>
231+
Cancel
232+
</Button>
127233
</div>
128234
</form>
129235
)}
@@ -151,36 +257,70 @@ export default function SegmentsPage() {
151257
onClick={() => setExpanded(isExpanded ? null : seg.key)}
152258
>
153259
<div className="min-w-0">
154-
<p className="font-mono text-sm font-medium text-slate-900">{seg.key}</p>
155-
<p className="mt-0.5 text-xs text-slate-500">{seg.name} &middot; Match {seg.match_type} &middot; {seg.rules?.length || 0} rules</p>
156-
{seg.description && <p className="mt-0.5 text-xs text-slate-400">{seg.description}</p>}
260+
<p className="font-mono text-sm font-medium text-slate-900">
261+
{seg.key}
262+
</p>
263+
<p className="mt-0.5 text-xs text-slate-500">
264+
{seg.name} &middot; Match {seg.match_type} &middot;{" "}
265+
{seg.rules?.length || 0} rules
266+
</p>
267+
{seg.description && (
268+
<p className="mt-0.5 text-xs text-slate-400">
269+
{seg.description}
270+
</p>
271+
)}
157272
</div>
158273
<div className="flex items-center gap-2 shrink-0">
159274
{deleting === seg.key ? (
160-
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
161-
<Button size="sm" variant="destructive-ghost" onClick={() => handleDelete(seg.key)}>Confirm</Button>
162-
<Button size="sm" variant="ghost" onClick={() => setDeleting(null)}>Cancel</Button>
275+
<div
276+
className="flex items-center gap-1"
277+
onClick={(e) => e.stopPropagation()}
278+
>
279+
<Button
280+
size="sm"
281+
variant="destructive-ghost"
282+
onClick={() => handleDelete(seg.key)}
283+
>
284+
Confirm
285+
</Button>
286+
<Button
287+
size="sm"
288+
variant="ghost"
289+
onClick={() => setDeleting(null)}
290+
>
291+
Cancel
292+
</Button>
163293
</div>
164294
) : (
165295
<Button
166296
size="icon-sm"
167297
variant="ghost"
168-
onClick={(e) => { e.stopPropagation(); setDeleting(seg.key); }}
298+
onClick={(e) => {
299+
e.stopPropagation();
300+
setDeleting(seg.key);
301+
}}
169302
title="Delete segment"
170303
className="text-slate-400 hover:text-red-500 hover:bg-red-50"
171304
>
172305
<Trash2 className="h-4 w-4" />
173306
</Button>
174307
)}
175-
<ChevronDown className={cn("h-4 w-4 text-slate-400 transition-transform duration-200", isExpanded && "rotate-180")} />
308+
<ChevronDown
309+
className={cn(
310+
"h-4 w-4 text-slate-400 transition-transform duration-200",
311+
isExpanded && "rotate-180",
312+
)}
313+
/>
176314
</div>
177315
</div>
178316
{isExpanded && (
179317
<div className="border-t border-slate-100 px-4 py-4 bg-slate-50/50 sm:px-6 animate-fade-in">
180318
<SegmentRulesEditor
181319
rules={seg.rules ?? []}
182320
matchType={seg.match_type}
183-
onSave={(rules, matchType) => handleSaveRules(seg.key, rules, matchType)}
321+
onSave={(rules, matchType) =>
322+
handleSaveRules(seg.key, rules, matchType)
323+
}
184324
/>
185325
</div>
186326
)}

0 commit comments

Comments
 (0)