Files
pn-new-crm/src/components/admin/forms/form-template-form.tsx
Matt c8ea9ec0a0 fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:

- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/

The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.

Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.

Test suite stays at 1315/1315 vitest. typescript clean.

Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00

243 lines
8.2 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { FormField } from '@/lib/validators/form-templates';
interface FormTemplate {
id: string;
name: string;
description: string | null;
fields: FormField[];
isActive: boolean;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
template: FormTemplate | null;
onSaved: () => void;
}
const DEFAULT_FIELD: FormField = {
key: '',
label: '',
type: 'text',
required: false,
};
const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [
{ value: 'text', label: 'Text' },
{ value: 'textarea', label: 'Long text' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'number', label: 'Number' },
{ value: 'select', label: 'Select' },
{ value: 'checkbox', label: 'Checkbox' },
];
export function FormTemplateForm(props: Props) {
// Key-based remount seeds state on each open + template change.
return (
<FormTemplateFormBody
key={props.open ? `open:${props.template?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) {
const [name, setName] = useState(template?.name ?? '');
const [description, setDescription] = useState(template?.description ?? '');
const [isActive, setIsActive] = useState(template?.isActive ?? true);
const [fields, setFields] = useState<FormField[]>(
template && template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }],
);
const saveMutation = useMutation({
mutationFn: () => {
const payload = {
name,
description: description || undefined,
fields,
isActive,
};
if (template) {
return apiFetch(`/api/v1/admin/form-templates/${template.id}`, {
method: 'PATCH',
body: payload,
});
}
return apiFetch('/api/v1/admin/form-templates', {
method: 'POST',
body: payload,
});
},
onSuccess: () => {
toast.success(template ? 'Template saved' : 'Template created');
onSaved();
onOpenChange(false);
},
onError: (err) => toastError(err),
});
function updateField(idx: number, patch: Partial<FormField>) {
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
}
function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
}
function removeField(idx: number) {
setFields((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)));
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{template ? 'Edit form template' : 'New form template'}</SheetTitle>
</SheetHeader>
<div className="space-y-4 py-4">
<div className="space-y-1">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Description</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<Label>Active</Label>
</div>
<div className="border-t pt-3 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Fields</Label>
<Button variant="outline" size="sm" onClick={addField}>
<Plus className="h-3.5 w-3.5 mr-1" aria-hidden />
Add field
</Button>
</div>
{fields.map((f, i) => (
<div key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Field {i + 1}</span>
{fields.length > 1 && (
<Button
variant="ghost"
size="icon"
className="text-destructive h-7 w-7"
onClick={() => removeField(i)}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label>
<Input
value={f.key}
onChange={(e) => updateField(i, { key: e.target.value })}
placeholder="email"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={f.label}
onChange={(e) => updateField(i, { label: e.target.value })}
placeholder="Email address"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select
value={f.type}
onValueChange={(v) => updateField(i, { type: v as FormField['type'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 flex items-end gap-2 pb-1">
<Switch
checked={!!f.required}
onCheckedChange={(v) => updateField(i, { required: v })}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
{f.type === 'select' && (
<div className="space-y-1">
<Label className="text-xs">Options (comma-separated)</Label>
<Input
value={(f.options ?? []).join(', ')}
onChange={(e) =>
updateField(i, {
options: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</div>
)}
</div>
))}
</div>
</div>
<SheetFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={
saveMutation.isPending ||
!name.trim() ||
fields.some((f) => !f.key.trim() || !f.label.trim())
}
>
{saveMutation.isPending ? 'Saving…' : 'Save template'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}