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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user