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;
|
pageY: number;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: 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 {
|
interface DetectedFieldResponse {
|
||||||
@@ -516,6 +529,9 @@ function DialogBody({
|
|||||||
pageY: f.pageY,
|
pageY: f.pageY,
|
||||||
pageWidth: f.pageWidth,
|
pageWidth: f.pageWidth,
|
||||||
pageHeight: f.pageHeight,
|
pageHeight: f.pageHeight,
|
||||||
|
...(f.fieldMeta && Object.keys(f.fieldMeta).length > 0
|
||||||
|
? { fieldMeta: f.fieldMeta }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1421,9 +1437,227 @@ function FieldSidePanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldMetaSubPanel field={field} onUpdate={onUpdate} />
|
||||||
|
|
||||||
<Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
|
<Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
|
||||||
<Trash2 className="size-4" aria-hidden /> Delete field
|
<Trash2 className="size-4" aria-hidden /> Delete field
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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