feat(upload-for-signing): per-type field metadata panel + payload extension

- PlacedField gains optional defaultValue + fieldMeta carriers. The
  field-placement submit threads fieldMeta verbatim through the FormData
  payload (only when populated), where the API route + service +
  Documenso client already accepted it (v2 field/create-many honours
  fieldMeta per row).
- FieldSidePanel grows a FieldMetaSubPanel that renders per-type
  controls in the right rail:
  - TEXT — default text, label, required toggle
  - NUMBER — format string, min, max, required
  - CHECKBOX — multi-select option editor with per-option `checked`
  - RADIO — single-select option editor (mutually-exclusive default)
  - DROPDOWN — single-select option editor
  Each writes shallowly into field.fieldMeta so Documenso v2's
  create-many endpoint receives the shape it expects. SIGNATURE /
  INITIALS / DATE / EMAIL / NAME render nothing (no per-instance
  config today).
- ChoiceMetaEditor extracted as a top-level component so the option
  list doesn't recreate its DOM subtree on every keystroke
  (react-hooks/static-components rule).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:45:39 +02:00
parent 866b910ae9
commit c4450dd852

View File

@@ -93,6 +93,19 @@ interface PlacedField {
pageY: number;
pageWidth: number;
pageHeight: number;
/** Per-instance default value the signer sees on render. For TEXT/NUMBER
* this is the prefilled string; for DROPDOWN it's the option `value`
* string; for CHECKBOX/RADIO the meta.values entries carry their own
* `checked` flags so this is left null. */
defaultValue?: string | null;
/** Documenso v2 fieldMeta passed through verbatim. Per-type shape:
* TEXT/SIGNATURE/INITIALS: { text?, label?, required?, readOnly? }
* NUMBER: { numberFormat?, min?, max?, required? }
* DATE: { dateFormat?, required? }
* CHECKBOX/RADIO: { values: [{ value, checked? }] }
* DROPDOWN: { values: [{ value }], defaultValue? }
* Ignored on v1 instances. */
fieldMeta?: Record<string, unknown>;
}
interface DetectedFieldResponse {
@@ -516,6 +529,9 @@ function DialogBody({
pageY: f.pageY,
pageWidth: f.pageWidth,
pageHeight: f.pageHeight,
...(f.fieldMeta && Object.keys(f.fieldMeta).length > 0
? { fieldMeta: f.fieldMeta }
: {}),
})),
),
);
@@ -1421,9 +1437,227 @@ function FieldSidePanel({
/>
</div>
</div>
<FieldMetaSubPanel field={field} onUpdate={onUpdate} />
<Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
<Trash2 className="size-4" aria-hidden /> Delete field
</Button>
</div>
);
}
/**
* Editable list for CHECKBOX / RADIO / DROPDOWN options. Kept top-level
* so React doesn't recreate the component subtree on every keystroke
* (react-hooks/static-components rule). Single-select variants render
* radio inputs that mutually exclude defaults; multi-select renders
* checkboxes.
*/
function ChoiceMetaEditor({
options,
onChange,
singleSelect,
}: {
options: Array<{ value: string; checked?: boolean }>;
onChange: (next: Array<{ value: string; checked?: boolean }>) => void;
singleSelect: boolean;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Options</Label>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() =>
onChange([...options, { value: `Option ${options.length + 1}`, checked: false }])
}
>
+ Add
</Button>
</div>
{options.length === 0 ? (
<p className="text-xs text-muted-foreground">No options yet, add at least one.</p>
) : (
<ul className="space-y-1">
{options.map((opt, idx) => (
<li key={idx} className="flex items-center gap-1.5">
<input
type={singleSelect ? 'radio' : 'checkbox'}
name="choice-meta"
checked={!!opt.checked}
onChange={() => {
const next = options.map((o, i) =>
singleSelect
? { ...o, checked: i === idx }
: i === idx
? { ...o, checked: !o.checked }
: o,
);
onChange(next);
}}
aria-label={`${opt.value} default ${singleSelect ? 'selection' : 'checked'}`}
className="size-3.5 shrink-0"
/>
<Input
value={opt.value}
onChange={(e) => {
const next = options.map((o, i) =>
i === idx ? { ...o, value: e.target.value } : o,
);
onChange(next);
}}
className="h-7 text-xs"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0 shrink-0"
onClick={() => onChange(options.filter((_, i) => i !== idx))}
aria-label="Remove option"
>
<X className="size-3" aria-hidden />
</Button>
</li>
))}
</ul>
)}
</div>
);
}
/**
* Per-field-type config (Documenso v2 fieldMeta). Surfaces inputs only
* when the field type carries per-instance config; SIGNATURE / INITIALS /
* DATE / EMAIL / NAME fields render nothing here. Edits write into
* `field.fieldMeta` shallowly so the v2 create-many payload receives
* the shape Documenso expects.
*/
function FieldMetaSubPanel({
field,
onUpdate,
}: {
field: PlacedField;
onUpdate: (patch: Partial<PlacedField>) => void;
}) {
const meta: Record<string, unknown> = (field.fieldMeta as Record<string, unknown>) ?? {};
function patchMeta(diff: Record<string, unknown>) {
onUpdate({ fieldMeta: { ...meta, ...diff } });
}
if (field.type === 'TEXT') {
return (
<div className="space-y-2 rounded-md border bg-muted/30 p-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Text settings
</p>
<div className="space-y-1">
<Label className="text-xs">Default text</Label>
<Input
value={typeof meta.text === 'string' ? meta.text : ''}
onChange={(e) => patchMeta({ text: e.target.value })}
className="h-7 text-xs"
placeholder="Pre-filled value the signer sees"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={typeof meta.label === 'string' ? meta.label : ''}
onChange={(e) => patchMeta({ label: e.target.value })}
className="h-7 text-xs"
placeholder="Helper text above the field"
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={meta.required === true}
onChange={(e) => patchMeta({ required: e.target.checked })}
className="size-3.5"
/>
Required
</label>
</div>
);
}
if (field.type === 'NUMBER') {
return (
<div className="space-y-2 rounded-md border bg-muted/30 p-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Number settings
</p>
<div className="space-y-1">
<Label className="text-xs">Format</Label>
<Input
value={typeof meta.numberFormat === 'string' ? meta.numberFormat : ''}
onChange={(e) => patchMeta({ numberFormat: e.target.value })}
className="h-7 text-xs"
placeholder="e.g. 0.00, $#,##0"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="space-y-1">
<Label className="text-xs">Min</Label>
<Input
type="number"
value={typeof meta.min === 'number' ? meta.min : ''}
onChange={(e) =>
patchMeta({ min: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Max</Label>
<Input
type="number"
value={typeof meta.max === 'number' ? meta.max : ''}
onChange={(e) =>
patchMeta({ max: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="h-7 text-xs"
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={meta.required === true}
onChange={(e) => patchMeta({ required: e.target.checked })}
className="size-3.5"
/>
Required
</label>
</div>
);
}
if (field.type === 'CHECKBOX' || field.type === 'RADIO' || field.type === 'DROPDOWN') {
const rawValues = (meta.values as Array<{ value: string; checked?: boolean }>) ?? [];
return (
<div className="space-y-2 rounded-md border bg-muted/30 p-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{field.type === 'CHECKBOX'
? 'Checkbox options'
: field.type === 'RADIO'
? 'Radio options'
: 'Dropdown options'}
</p>
<ChoiceMetaEditor
options={rawValues}
onChange={(next) => patchMeta({ values: next })}
singleSelect={field.type !== 'CHECKBOX'}
/>
</div>
);
}
// SIGNATURE / INITIALS / DATE / EMAIL / NAME carry no per-instance
// configuration in Documenso v2 today.
return null;
}