From 6178e0a6a3937b36b253d099c0ddc513b705aa2f Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 18:41:24 +0530 Subject: [PATCH] feat(ui): friendly Add-a-field form for module fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/pages/Modules.tsx | 253 ++++++++++++++++++------------ 1 file changed, 152 insertions(+), 101 deletions(-) diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index c8d6724..6aa80e1 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -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 = { + text: 'Text', number: 'Number', date: 'Date', boolean: 'Yes / No', + select: 'Choice from a list', secret: 'Secret (hidden, revealable)', +} +const TYPE_HINT: Record = { + 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 { + 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(() => props.module.fieldSpec.map(toDraft)) + const [fields, setFields] = useState(() => [...props.module.fieldSpec]) + const [edit, setEdit] = useState(null) + const [busy, setBusy] = useState(false) const [error, setError] = useState() - const [saving, setSaving] = useState(false) - const setRow = (i: number, patch: Partial) => - 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 ( <>

Fields — the details you record for each client on this module

- {!props.isOwner ? ( - props.module.fieldSpec.length === 0 ? No fields defined. : ( - ({ - label: f.label, k: f.key, type: f.type, - req: f.required === true ? required : '—', - options: f.type === 'select' ? (f.options ?? []).join(', ') : '—', - help: f.help !== undefined && f.help !== '' ? f.help : '—', - }))} - /> - ) + + {fields.length === 0 ? ( + No fields yet{props.isOwner ? ' — add the first with the button below.' : '.'} ) : ( -
-

- The operational fields every client of this module fills in — a new field shows up on - every service card immediately, no code change. -

- {rows.length === 0 ? No fields yet — add the first below. : rows.map((r, i) => ( -
- - - - - setRow(i, { label: e.target.value })} - /> - setRow(i, { key: e.target.value })} - /> - - {r.type === 'select' && ( - setRow(i, { options: e.target.value })} - /> +
+ {fields.map((f, i) => ( +
+
+
+ {f.label} + {f.required === true && required} +
+
+ {TYPE_LABEL[f.type]} + {f.type === 'select' && (f.options ?? []).length > 0 ? ` · ${(f.options ?? []).join(', ')}` : ''} + {f.help !== undefined && f.help !== '' ? ` · ${f.help}` : ''} +
+
+ {props.isOwner && ( + + + + + + )} - setRow(i, { help: e.target.value })} - /> - -
))} - - - - - - {error !== undefined && {error}}
)} + + {props.isOwner && ( + + + + {busy ? 'Saving…' : 'Changes save as you go. New fields appear on every client of this module.'} + + + )} + {error !== undefined && edit === null && {error}} + + {edit !== null && ( + setEdit(null)} + title={edit.index === null ? 'Add a field' : `Edit field — ${edit.label || ''}`} + footer={ + <> + + + + } + > + + + setEdit({ ...edit, label: e.target.value })} /> + + + + {TYPE_HINT[edit.type]} + + {edit.type === 'select' && ( + + setEdit({ ...edit, options: e.target.value })} /> + + )} + + setEdit({ ...edit, help: e.target.value })} /> + + + + + + {error !== undefined && {error}} + + )} ) }