Repository navigation
Define and enforce the form widget contract (forms ↔ widgets) #243
Description
Summary
Aurora's schema-driven forms (content add/edit, control panels, block settings) render each field through one dispatcher, Field.tsx, which looks up a widget in the registry and renders it. Right now there is no defined contract between the form and the widgets. Each side guesses what the other one sends, and the guesses don't match. As a result:
- no text field shows its required marker or its validation errors;
- most registered widgets ignore some of the props they receive;
- some widgets only work inside the content edit route;
- add-on authors can't know what props their widget will get, or what value it should emit.
This issue lists the inconsistencies on main (as of ca58740) and proposes a contract plus a plan to fix it in separate PRs.
#97 started this work. It adds a conceptual guide ("Form fields, controls, and widgets") and a first FormWidgetProps / FormWidget type in @plone/types. This issue is the inventory and roadmap behind it.
Vocabulary (from #97)
- Field: one piece of form data: name, value, validation state, schema metadata. It is owned by TanStack Form.
- Control: a design-system interaction component (
@plone/quanta, React Aria). It has its own API (isSelected,isRequired,DateValue…) and knows nothing about Plone. - Widget: what the registry returns for a schema field. It takes the widget contract and emits normalized values.
- Adapter: a widget that wraps a control and maps the contract to the control's API.
Rule: controls are never registered directly. Only widgets are.
Inconsistencies on main
A. Form side (what the forms send to widgets)
- Raw schema properties are spread into the widget props. All three forms do
{...schema.properties[schemaField]}: ContentForm.tsx#L116, controlpanel.tsx#L115, BlockSettingsFormRenderer.tsx#L45. Widgets therefore receivetype,title,default,factory,minLength,onChangeSideEffects, etc. mixed in with real props.type: "string" | "integer" | "array"collides with thetypeattribute of the defaultTextField, which spreads its props onto React AriaTextField. - The field rendering loop is copied three times. Each copy builds props slightly differently: content and control panel pass
formAtomandvalue, block settings passesgetFieldProps. There is no single place that builds widget props. defaultValueis set to the current value.defaultValue={field.state.value}sits next tovalue={field.state.value}(ContentForm.tsx#L122, controlpanel.tsx#L119). The schemadefaultis never passed as such. Some widgets (ObjectBrowserWidget) only work because they readdefaultValueonce on mount.- Errors are passed as
error: unknown[], which no control understands. Quanta controls expecterrorMessageandisInvalid, because React Aria'sFieldErrorrenders nothing unless the field is invalid. Today onlyBooleanWidgetconvertserroritself. No other widget ever shows a validation error. requiredis passed, but controls expectisRequired. Text, date, datetime and radio-based widgets never show the required state.onBluris not wired, so TanStack Form'stouchedstate and blur validation never run. (Document and type the form widget contract #97 adds this.)- Every widget gets a placeholder.
placeholder: fieldProps.placeholder || 'Type something...'(Field.tsx#L186) applies to checkboxes, pickers and so on, and it isn't translated. - The caller's
labelis overwritten.label: fieldProps.title(Field.tsx#L185) replaces thelabelthe caller passed. - No
disabled/readOnly. Schemareadonlyand form-level disabled states are not mapped to anything. - Naming. The dispatcher is registered as the TanStack field component
Quanta(Form.tsx), so call sites read<field.Quanta>. The name points to a design system instead of the role ("schema field").
B. Widget resolution
-
An unknown
widgetname falls back to the default widget instead of continuing the lookup. Field.tsx#L79 returnsgetWidgetDefault(), so choices, vocabulary, factory and type are never tried. This is the cause described in Add Image Content tab crashes because the image field is rendered as a TextField #163:widget: "file"is not registered, so the factory is never consulted and the field gets aTextField. Today that still affects the File type'sfilefield. Image'simagefield only escapes because of B2. -
config.getWidget(key)ignores the category. It searches every category (id,widget,vocabulary,factory,type) and returns the first hit (registry index.ts#L606). The categories are therefore only labels: a field namedimagematches theimagewidget, andgetWidgetByType('boolean')only works becausebooleanis (wrongly) registered under thewidgetcategory (widgets.ts#L30). -
choices/vocabularylookup returns nothing.config.widgets.choicesis read (Field.tsx#L152) but never registered, so select and vocabulary fields render as a text input. -
Core Plone widgets are missing from the registry:
textarea: plone.restapi sends it fordescription, and Quanta already hasTextAreaField;- number/integer;
array/ tokens (subjects);file;password,email,url.
All of them render as a single-line
TextField.
C. Widgets (what widgets accept and emit)
| Registered as | Component | Problem |
|---|---|---|
| default | Quanta TextField (control) |
React Aria API: ignores required/error, gets type from the schema, value: null not handled |
widget: date |
@plone/components DateField (control, not Quanta) |
Takes and emits a React Aria DateValue object, not an ISO YYYY-MM-DD string |
widget: datetime |
Quanta DateTimePicker (control) |
Value contract is fine (ISO string), but ignores required/error |
widget: align/size/width |
Quanta *Widget (controls named "widget") |
RadioGroup API; label/required/error not handled |
widget: boolean |
cmsui BooleanWidget (adapter) |
✅ The reference adapter, but it converts errors itself and has its own props interface |
widget: image |
cmsui ImageWidget |
onChange(value, extras): a second argument to update sibling fields (image_field, image_scales); reads errorMessage, which is never sent |
widget: object_browser, factory: Relation List, vocabulary: Catalog |
cmsui ObjectBrowserWidget |
Ignores value (uncontrolled, via defaultValue); emits Partial<Brain>[]; reads useLoaderData(editLoader), so it only works inside the content edit route |
id: recurrence |
cmsui RecurrenceWidget |
Ignores value; reads the content form's global formAtom from routes/atoms to get start/end; typed with the dispatcher's own FieldProps |
widget: querystring |
cmsui QuerystringWidget |
Value contract is fine; reads errorMessage, which is never sent |
This breaks the requirement already stated in #6: widgets should be "dumb" components that receive the data as props, set data in onChange, with no external dependencies.
D. Types
WidgetsConfigById/ByWidget/ByFactory/ByType/ByVocabularyare allReact.ComponentType<any>, soregisterWidgetaccepts any component and the contract can't be checked at compile time.- There is no shared props type. Each widget declares its own (
BaseFormFieldProps = Pick<QuantaTextFieldProps, …>in three files,BooleanWidgetProps, the dispatcher'sFieldProps). Document and type the form widget contract #97 addsFormWidgetProps<T>/FormWidget<T>.
Proposed contract
What every widget receives (FormWidgetProps<T>)
interface FormWidgetProps<T> {
// identity
id: string; // unique DOM id, built by the form
name: string; // field path in the form data
// value
value?: T | null; // stored value; null/undefined when empty
onChange: (value: T) => void; // always emits the normalized value (T includes null for clearable widgets)
onBlur?: () => void;
// presentation
label?: string;
description?: string;
placeholder?: string; // only when the schema provides one
// state, already normalized by the form
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
invalid?: boolean;
errorMessage?: string; // joined, translated message
// data sources
choices?: Array<[value: string, label: string]>;
vocabulary?: { '@id': string };
// escape hatch: the raw field schema, never spread
schema?: FieldSchema;
}Plus any widgetProps from frontendOptions (tagged values), merged on top as widget-specific props.
Rules
- The form normalizes and the widget renders. Converting errors, mapping
required, resolving labels and applying defaults all happens once, in the dispatcher. Widgets never seeerror,titleordefault. - Widgets are controlled. They render
valueand report changes throughonChange. They don't keep a parallel source of truth, and they don't read form atoms or route loaders. - The value shape is per widget and documented. Each registered widget names the JSON value it emits, and that value must be what plone.restapi accepts (e.g.
date→"YYYY-MM-DD",datetime→ ISO 8601 with offset, relations →[{ '@id': … }], boolean →boolean). Conversion to and from control types (DateValue,Brain) happens inside the adapter. - Widgets get context through a hook, not imports. Widgets that need the content (object browser: current path) or sibling values (recurrence: start/end) use a widget context hook provided by every form (e.g.
useWidgetContext()→{ content, getFieldValue, setFieldValue }). Updates to sibling fields go through that hook or the existing schema-levelonChangeSideEffects(already used by block settings), not through extraonChangearguments. - Controls stay in
@plone/quanta, widgets live in@plone/cmsui. Every registered entry is an adapter (or a native widget) typedFormWidget<T>.- The API a component speaks decides its package, not how simple it is. Quanta is the CMS design system: it speaks React Aria, doesn't know the widget contract, and doesn't depend on
@plone/types. An adapter speaks the widget contract, so it lives in cmsui, however small it is.BooleanWidgetis the example: a few lines mappingvalue→isSelected, but an adapter, so it stays in cmsui next toObjectBrowserWidget. - Quanta components are named after what they are.
AlignWidget,SizeWidgetandWidthWidgetare controls (typedOmit<RadioGroupProps, 'children'>) named like widgets, which is what makes them look like peers ofBooleanWidget. They get cmsui adapters, and are renamed (step 4). - A form for another audience (e.g. the public form generator, [Aurora] - Form generator #93) uses its own design system's controls, so it needs its own adapters. If adapters ever need to be shared between the CMS and public forms, they go in a package between the design systems and the apps, not inside one design system.
- Documented in the guide's "Where controls and widgets live" section (Document and type the form widget contract #97).
- The API a component speaks decides its package, not how simple it is. Quanta is the CMS design system: it speaks React Aria, doesn't know the widget contract, and doesn't depend on
- Resolution is deterministic and category-scoped. id →
frontendOptions.widget→widget→ choices/vocabulary → factory → type → default. Each step looks only in its own category, and a miss continues to the next step.
Plan (one PR per item)
-
1. Contract types and docs: Document and type the form widget contract #97 (open). Add
FormWidgetProps/FormWidgetto@plone/typesand the conceptual guide, and wireonBlur. Update the guide to describe the contract above and link back here. -
2. One schema field renderer for all forms: Render every schema form through one field renderer #251 (open, stacked on Document and type the form widget contract #97). Pull the field loop and prop building out of
ContentForm,controlpanelandBlockSettingsFormRendererinto one shared function that builds props from an allowlist (no schema spreading). In it:- convert errors to
invalid+errorMessage; - map
required,readonly→readOnly, anddisabled; - pass the schema
defaultcorrectly instead ofdefaultValue = value; - remove the forced placeholder;
- pass
schemaas its own prop; - rename
field.Quanta→field.SchemaField.
Covers A1–A10.
- convert errors to
-
3. Fix widget resolution: Scope widget lookups to their category #249 (merged).
getWidget(key, category?)in@plone/registry, scoped lookup inField.tsx;- an unknown
widgetname falls through instead of returning the default; - register
booleanundertype; - unit tests for the resolution order.
Covers B1–B2 and unblocks Add Image Content tab crashes because the image field is rendered as a TextField #163.
-
4. Adapters for the controls registered today: Validate schema-driven forms #256 + Add adapters for the remaining registered controls #260 (open, stacked). Covers C rows 1–4.
-
BooleanWidgeton the sharedFormWidgetProps, with no error conversion of its own: Document and type the form widget contract #97 / Render every schema form through one field renderer #251. -
TextWidget(the default widget) andDateTimeWidget: Validate schema-driven forms #256, needed to show validation errors. -
TextareaWidget(registertextarea): Add adapters for the remaining registered controls #260. -
DateWidget: Add adapters for the remaining registered controls #260, around the QuantaDatePicker, which already works with ISO strings. - Cmsui adapters for align/size/width, registered under
align,sizeandwidth: Add adapters for the remaining registered controls #260. - Renamed Quanta's
AlignWidget,SizeWidgetandWidthWidgettoAlignPicker,SizePickerandWidthPicker(after Quanta'sDatePicker), keeping the old names as deprecated aliases (rule 5): Add adapters for the remaining registered controls #260.
-
-
5. Widget context, and decoupled widgets: Give widgets a context instead of the route #261 (open, stacked on Add adapters for the remaining registered controls #260). Closes Add Image Content tab crashes because the image field is rendered as a TextField #163.
-
useWidgetContext()(mode,path,containerPath), provided by the content form, the control panels and (through the content form) the block settings. Form values are read with the@plone/helpershooks. -
ObjectBrowserWidget: starts from the context instead of the edit route's data; its selection followsvalue. -
RecurrenceWidget: reads its ownvalue; readsstart/endthrough the form hooks (no globalformAtomsince Move the forms onto the helpers form store #253). -
ImageWidget: browses and uploads from the context instead of the URL (Add Image Content tab crashes because the image field is rendered as a TextField #163); its extras go to sibling fields through theextraFieldswidget option. -
QuerystringWidget: no edit-route read. - Document the relation value shape the object browser emits: the "Core widgets" reference, in Give widgets a context instead of the route #261.
-
-
6. Typed registry and contract tests: Type-check registered widgets and test them against the contract #262 (open, stacked on Give widgets a context instead of the route #261).
- The widget registry types its slots with the props an app declares in
WidgetPropsMap(@plone/types);@plone/cmsuideclaresFormWidgetProps, so a non-conforming widget is a type error in Aurora, while Volto, which declares nothing, is unaffected. It found and fixed one mismatch (ObjectBrowserWidget'swidgetOptions). -
describeWidgetContract(@plone/cmsui/testing/widgetContract), run by every core widget; documented for add-ons. - Per-widget value shapes and widget options: the "Core widgets" reference, in Give widgets a context instead of the route #261.
- The widget registry types its slots with the props an app declares in
-
7. Missing core widgets. (Add the missing core widgets #263) Choices/select (B3), number/integer, array/tokens ([CMSUI Widgets] - ArrayWidget #8), file (Add Image Content tab crashes because the image field is rendered as a TextField #163), password/email/url. Each one built against the contract and passing the contract tests. Tracks [CMSUI] - Widgets #6.
Steps 2 and 3 are independent and can go in parallel. Step 4 depends on 2 (so adapters receive normalized props). Step 6 is easier once 4 and 5 have landed.
Related: #6, #8, #9, #89 (display mode widgets: same contract, read-only side), #93 (generic form generator: should reuse the renderer from step 2), #97, #163.
Activity
sneridagh commentedon Oct 8, 2026
Related: #244 covers the form engine underneath (Jotai as the single source of truth, dropping the TanStack Form mirror). The contract here doesn't depend on it: the step 2 field renderer will sit on top of whichever engine #244 settles on.
sneridagh commentedon Oct 8, 2026
/cc @pnicolli
Scope widget lookups to their category (#249)
Give widgets a context instead of the route
Render every schema form through one field renderer (#251)
Add adapters for the remaining registered controls (#260)
Give widgets a context instead of the route (#261)
Type-check registered widgets and test them against the contract (#262)