feat(ui): friendly Add-a-field form for module fields

Replaces the raw per-row grid (label/key/type/options/help/required inputs) with:
- Existing fields as clean cards (name, plain-language type, required badge, options, hint)
  with Edit / Remove / reorder.
- '+ Add a field' opens a dialog in plain language: Field name, 'What kind of
  information?' (Text / Number / Date / Yes-No / Choice from a list / Secret, each with a
  one-line hint), Options (only for a list), Hint, Required?.
- The internal lower_snake_case key is AUTO-derived from the field name (unique) and never
  shown — existing fields keep their key (it ties to stored data). Each add/edit/remove/
  reorder saves immediately (no separate Save step). typecheck + build clean; backend
  fieldSpec validation unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 8485e69ff0
commit 6178e0a6a3

@ -443,126 +443,177 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
}
/**
* D21 "Fields" editor where a module DEFINES ITSELF (config over code): the list of
* operational fields (key/label/type/options/required/help) that every client of this
* module then fills in on its service card. Owner edits; staff read a compact list.
* A blank options list on a `select` and a bad key are rejected server-side (validateFieldSpec).
* D21 "Fields" where a module DEFINES ITSELF (config over code): the details every
* client of this module then fills in on its service card. Friendly UX (2026-07): existing
* fields are cards; Add/Edit go through a plain-language dialog; the internal key is
* auto-derived from the field name and never shown to the user. Each add/edit/remove/reorder
* persists immediately (patchModule), so there's no separate "save" step.
*/
interface FieldDraft { key: string; label: string; type: FieldType; options: string; required: boolean; help: string }
const toDraft = (f: FieldDef): FieldDraft => ({
key: f.key, label: f.label, type: f.type,
options: (f.options ?? []).join(', '), required: f.required === true, help: f.help ?? '',
})
type FriendlyType = FieldType
const TYPE_LABEL: Record<FriendlyType, string> = {
text: 'Text', number: 'Number', date: 'Date', boolean: 'Yes / No',
select: 'Choice from a list', secret: 'Secret (hidden, revealable)',
}
const TYPE_HINT: Record<FriendlyType, string> = {
text: 'Any words or value — names, IDs, notes.',
number: 'A number only.',
date: 'A calendar date.',
boolean: 'A simple yes or no.',
select: 'Pick one from a fixed list you set.',
secret: 'A password/key — stored hidden, shown only on request.',
}
/** Make a valid lower_snake_case key from a human label, unique against the taken set. */
function keyFromLabel(label: string, taken: Set<string>): string {
let base = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
if (base === '' || !/^[a-z]/.test(base)) base = `field_${base}`.replace(/_+$/g, '')
let key = base, n = 2
while (taken.has(key)) { key = `${base}_${n++}` }
return key
}
interface FieldEdit { index: number | null; key: string; label: string; type: FriendlyType; options: string; required: boolean; help: string }
const EMPTY_EDIT: FieldEdit = { index: null, key: '', label: '', type: 'text', options: '', required: false, help: '' }
function FieldSpecEditor(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast()
const [rows, setRows] = useState<FieldDraft[]>(() => props.module.fieldSpec.map(toDraft))
const [fields, setFields] = useState<FieldDef[]>(() => [...props.module.fieldSpec])
const [edit, setEdit] = useState<FieldEdit | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const setRow = (i: number, patch: Partial<FieldDraft>) =>
setRows((prev) => prev.map((r, j) => (j === i ? { ...r, ...patch } : r)))
const move = (i: number, dir: -1 | 1) =>
setRows((prev) => {
const j = i + dir
if (j < 0 || j >= prev.length) return prev
const next = [...prev]
;[next[i], next[j]] = [next[j]!, next[i]!]
return next
})
/** Persist a new ordered field list, re-stamping sort to match on-screen order. */
const persist = (next: FieldDef[]) => {
const spec = next.map((f, i) => ({ ...f, sort: i }))
setBusy(true); setError(undefined)
patchModule(props.module.id, { fieldSpec: spec })
.then(() => { setFields(spec); props.onSaved() })
.catch((e: Error) => { setError(e.message); toast.err(e.message) })
.finally(() => setBusy(false))
}
const save = () => {
if (saving) return
const cleaned = rows.filter((r) => r.key.trim() !== '' || r.label.trim() !== '')
// sort follows on-screen order so the service card renders top-to-bottom as arranged.
const spec = cleaned.map((r, i): FieldDef => ({
key: r.key.trim(), label: r.label.trim(), type: r.type, sort: i,
...(r.type === 'select'
? { options: r.options.split(',').map((o) => o.trim()).filter((o) => o !== '') }
: {}),
...(r.required ? { required: true } : {}),
...(r.help.trim() !== '' ? { help: r.help.trim() } : {}),
}))
const move = (i: number, dir: -1 | 1) => {
const j = i + dir
if (j < 0 || j >= fields.length) return
const next = [...fields]
;[next[i], next[j]] = [next[j]!, next[i]!]
persist(next)
}
const remove = (i: number) => persist(fields.filter((_f, j) => j !== i))
const openAdd = () => { setError(undefined); setEdit({ ...EMPTY_EDIT }) }
const openEdit = (f: FieldDef, i: number) => {
setError(undefined)
setSaving(true)
patchModule(props.module.id, { fieldSpec: spec })
.then(() => { toast.ok('Fields saved'); setRows(spec.map(toDraft)); props.onSaved() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
setEdit({ index: i, key: f.key, label: f.label, type: f.type, options: (f.options ?? []).join(', '), required: f.required === true, help: f.help ?? '' })
}
const saveEdit = () => {
if (edit === null) return
const label = edit.label.trim()
if (label === '') { setError('Give the field a name'); return }
if (edit.type === 'select' && edit.options.split(',').map((o) => o.trim()).filter(Boolean).length === 0) {
setError('A “Choice from a list” field needs at least one option'); return
}
// New fields get an auto-derived key; existing fields keep theirs (it ties to stored data).
const taken = new Set(fields.filter((_f, j) => j !== edit.index).map((f) => f.key))
const key = edit.index === null ? keyFromLabel(label, taken) : edit.key
const field: FieldDef = {
key, label, type: edit.type, sort: 0,
...(edit.type === 'select' ? { options: edit.options.split(',').map((o) => o.trim()).filter(Boolean) } : {}),
...(edit.required ? { required: true } : {}),
...(edit.help.trim() !== '' ? { help: edit.help.trim() } : {}),
}
const next = edit.index === null ? [...fields, field] : fields.map((f, j) => (j === edit.index ? field : f))
setEdit(null)
persist(next)
}
return (
<>
<h3 style={{ marginTop: 20 }}>Fields <span style={{ fontWeight: 400, fontSize: 13, opacity: 0.65 }}> the details you record for each client on this module</span></h3>
{!props.isOwner ? (
props.module.fieldSpec.length === 0 ? <EmptyState>No fields defined.</EmptyState> : (
<DataTable
columns={[
{ key: 'label', label: 'Label' }, { key: 'k', label: 'Key', mono: true },
{ key: 'type', label: 'Type' }, { key: 'req', label: 'Required' },
{ key: 'options', label: 'Options' }, { key: 'help', label: 'Help' },
]}
rows={props.module.fieldSpec.map((f) => ({
label: f.label, k: f.key, type: f.type,
req: f.required === true ? <Badge tone="accent">required</Badge> : '—',
options: f.type === 'select' ? (f.options ?? []).join(', ') : '—',
help: f.help !== undefined && f.help !== '' ? f.help : '—',
}))}
/>
)
{fields.length === 0 ? (
<EmptyState>No fields yet{props.isOwner ? ' — add the first with the button below.' : '.'}</EmptyState>
) : (
<div className="wf-card">
<p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--text-dim)' }}>
The operational fields every client of this module fills in a new field shows up on
every service card immediately, no code change.
</p>
{rows.length === 0 ? <EmptyState>No fields yet add the first below.</EmptyState> : rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button disabled={i === 0} aria-label="Move field up" onClick={() => move(i, -1)}></Button>
<Button disabled={i === rows.length - 1} aria-label="Move field down" onClick={() => move(i, 1)}></Button>
</span>
<input
className="wf" placeholder="Label" style={{ width: 160 }} aria-label="Field label"
value={r.label} onChange={(e) => setRow(i, { label: e.target.value })}
/>
<input
className="wf mono" placeholder="key" style={{ width: 130 }} aria-label="Field key"
value={r.key} onChange={(e) => setRow(i, { key: e.target.value })}
/>
<select
className="wf" style={{ width: 110 }} aria-label="Field type"
value={r.type} onChange={(e) => setRow(i, { type: e.target.value as FieldType })}
>
{FIELD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{r.type === 'select' && (
<input
className="wf" placeholder="options, comma-separated" style={{ width: 200 }} aria-label="Select options"
value={r.options} onChange={(e) => setRow(i, { options: e.target.value })}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map((f, i) => (
<div key={f.key} className="wf-card" style={{ padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontWeight: 600 }}>
{f.label}
{f.required === true && <Badge tone="accent" >required</Badge>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-dim)', marginTop: 2 }}>
{TYPE_LABEL[f.type]}
{f.type === 'select' && (f.options ?? []).length > 0 ? ` · ${(f.options ?? []).join(', ')}` : ''}
{f.help !== undefined && f.help !== '' ? ` · ${f.help}` : ''}
</div>
</div>
{props.isOwner && (
<span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<Button disabled={busy || i === 0} aria-label="Move up" onClick={() => move(i, -1)}></Button>
<Button disabled={busy || i === fields.length - 1} aria-label="Move down" onClick={() => move(i, 1)}></Button>
<Button disabled={busy} onClick={() => openEdit(f, i)}>Edit</Button>
<Button tone="danger" disabled={busy} aria-label={`Remove ${f.label}`} onClick={() => remove(i)}>Remove</Button>
</span>
)}
<input
className="wf" placeholder="Help (optional)" style={{ width: 180 }} aria-label="Field help"
value={r.help} onChange={(e) => setRow(i, { help: e.target.value })}
/>
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={r.required} onChange={(e) => setRow(i, { required: e.target.checked })} />
required
</label>
<Button tone="danger" aria-label="Remove field" onClick={() => setRows((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div>
))}
<Toolbar>
<Button onClick={() => setRows((prev) => [...prev, { key: '', label: '', type: 'text', options: '', required: false, help: '' }])}>
+ Add field
</Button>
<span style={{ flex: 1 }} />
<Button tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save fields'}</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)}
{props.isOwner && (
<Toolbar>
<Button tone="primary" disabled={busy} onClick={openAdd}>+ Add a field</Button>
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
{busy ? 'Saving…' : 'Changes save as you go. New fields appear on every client of this module.'}
</span>
</Toolbar>
)}
{error !== undefined && edit === null && <Notice tone="err">{error}</Notice>}
{edit !== null && (
<Dialog
open
onClose={() => setEdit(null)}
title={edit.index === null ? 'Add a field' : `Edit field — ${edit.label || ''}`}
footer={
<>
<Button pill onClick={() => setEdit(null)}>Cancel</Button>
<Button pill tone="primary" onClick={saveEdit}>{edit.index === null ? 'Add field' : 'Save field'}</Button>
</>
}
>
<FormGrid>
<FormField label="Field name" wide>
<input className="wf" autoFocus placeholder="e.g. Installation date, Admin username…"
value={edit.label} onChange={(e) => setEdit({ ...edit, label: e.target.value })} />
</FormField>
<FormField label="What kind of information?" wide>
<select className="wf" value={edit.type} onChange={(e) => setEdit({ ...edit, type: e.target.value as FriendlyType })}>
{FIELD_TYPES.map((t) => <option key={t} value={t}>{TYPE_LABEL[t]}</option>)}
</select>
<span style={{ display: 'block', fontSize: 12, opacity: 0.6, marginTop: 4 }}>{TYPE_HINT[edit.type]}</span>
</FormField>
{edit.type === 'select' && (
<FormField label="Options (one per comma)" wide>
<input className="wf" placeholder="e.g. Paid, Pending, Overdue"
value={edit.options} onChange={(e) => setEdit({ ...edit, options: e.target.value })} />
</FormField>
)}
<FormField label="Hint (optional)" wide>
<input className="wf" placeholder="A note shown under the field to whoever fills it in"
value={edit.help} onChange={(e) => setEdit({ ...edit, help: e.target.value })} />
</FormField>
<FormField label="Required?">
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={edit.required} onChange={(e) => setEdit({ ...edit, required: e.target.checked })} />
Must be filled in
</label>
</FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)}
</>
)
}

Loading…
Cancel
Save