feat(uat-batch): M43 — form-template bindings + inline field history
Closes plan item 43 (Form-template fields bind to Interest/Client data —
autofill, override-preservation history, dual-surface audit trail).
Phase 1 — Editor:
- New bindable-fields catalog (src/lib/templates/bindable-fields.ts):
client/yacht/interest paths, each tagged with the entity, column, and
default input type. Source of truth for what can bind + what
interest_field_history.field_path strings the writers should use.
- formFieldSchema gains optional bindTo, validated against the catalog
as an allow-list (no arbitrary paths sneak through).
- form-template-form admin sheet: per-field "Bind to" dropdown grouped
by entity, auto-derives label/key/type when a binding is picked,
shows "Autofills from + writes back to {label} . {path}" badge.
Phase 2 — Runtime + history writes:
- supplemental-forms.service.applySubmission already wrote
interest_field_history rows for client name/email/address from the
earlier 0081 migration session. Extended to also capture phone +
yacht (name, length, width, draft) diffs that were silently going
to the entity without an audit row, and to push insert-path
overrides for the no-existing-address case.
- Field paths aligned with the bindable-fields catalog so detail-page
lookups work via exact-match WHERE field_path = ?.
Phase 3 — Inline history surface:
- New /api/v1/clients/[id]/field-history (mirror of the existing
interests endpoint).
- shared/field-history: FieldHistoryProvider wraps a detail tab and
fires a single keyed GET; FieldHistoryIcon consumes the context and
renders a small clock affordance only when at least one override
exists, opening a popover with the reverse-chrono diff list.
- Client + Interest detail Overview tabs wrapped in the provider;
EditableRow gains an optional historyPath prop; ContactsEditor
renders the icon next to the canonical primary email/phone.
1454/1454 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,14 +13,24 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import type { FormField } from '@/lib/validators/form-templates';
|
||||
import {
|
||||
bindableFieldsByEntity,
|
||||
getBindableField,
|
||||
type BindableType,
|
||||
} from '@/lib/templates/bindable-fields';
|
||||
|
||||
const BIND_TO_NONE = '__none__';
|
||||
|
||||
interface FormTemplate {
|
||||
id: string;
|
||||
@@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
|
||||
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
|
||||
}
|
||||
|
||||
function changeBinding(idx: number, raw: string) {
|
||||
if (raw === BIND_TO_NONE) {
|
||||
// Clear the binding but leave the rest of the field untouched —
|
||||
// admins may want to keep a custom field that no longer autofills.
|
||||
setFields((prev) =>
|
||||
prev.map((f, i) => {
|
||||
if (i !== idx) return f;
|
||||
const { bindTo, ...rest } = f;
|
||||
void bindTo;
|
||||
return rest;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const meta = getBindableField(raw);
|
||||
if (!meta) return;
|
||||
// Adopt the binding + auto-derive input type and label when the admin
|
||||
// hasn't typed one yet (saves repetitive data entry on the common
|
||||
// "bind to client email" flow). Pre-existing labels are preserved so
|
||||
// admins can override the friendly name per template.
|
||||
setFields((prev) =>
|
||||
prev.map((f, i) =>
|
||||
i === idx
|
||||
? {
|
||||
...f,
|
||||
bindTo: raw,
|
||||
type: coerceFieldType(meta.inputType, f.type),
|
||||
label: f.label.trim() ? f.label : meta.label,
|
||||
key: f.key.trim() ? f.key : meta.path.split('.').pop()!,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function addField() {
|
||||
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
|
||||
}
|
||||
@@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Bind to (autofill + write-back)</Label>
|
||||
<Select
|
||||
value={f.bindTo ?? BIND_TO_NONE}
|
||||
onValueChange={(v) => changeBinding(i, v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No binding (free-form)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={BIND_TO_NONE}>
|
||||
No binding - store in submission only
|
||||
</SelectItem>
|
||||
{bindableFieldsByEntity().map((group) => (
|
||||
<SelectGroup key={group.entity}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.fields.map((bf) => (
|
||||
<SelectItem key={bf.path} value={bf.path}>
|
||||
{bf.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{f.bindTo ? (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Autofills from + writes back to {getBindableField(f.bindTo)?.label} ·{' '}
|
||||
{f.bindTo}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Key (no spaces)</Label>
|
||||
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a bindable column's natural input type onto the form-field types we
|
||||
* actually render. When binding to a `number` column we still let the
|
||||
* admin keep `select` if they'd already chosen it (e.g. they want to
|
||||
* constrain to specific values) — same for `textarea`.
|
||||
*/
|
||||
function coerceFieldType(
|
||||
bindableType: BindableType,
|
||||
currentType: FormField['type'],
|
||||
): FormField['type'] {
|
||||
if (currentType === 'select' || currentType === 'checkbox') return currentType;
|
||||
if (currentType === 'textarea' && bindableType === 'text') return 'textarea';
|
||||
return bindableType;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user