Skip to content

Define and enforce the form widget contract (forms ↔ widgets) #243

Closed

Description

@sneridagh

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)

  1. 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 receive type, title, default, factory, minLength, onChangeSideEffects, etc. mixed in with real props. type: "string" | "integer" | "array" collides with the type attribute of the default TextField, which spreads its props onto React Aria TextField.
  2. The field rendering loop is copied three times. Each copy builds props slightly differently: content and control panel pass formAtom and value, block settings passes getFieldProps. There is no single place that builds widget props.
  3. defaultValue is set to the current value. defaultValue={field.state.value} sits next to value={field.state.value} (ContentForm.tsx#L122, controlpanel.tsx#L119). The schema default is never passed as such. Some widgets (ObjectBrowserWidget) only work because they read defaultValue once on mount.
  4. Errors are passed as error: unknown[], which no control understands. Quanta controls expect errorMessage and isInvalid, because React Aria's FieldError renders nothing unless the field is invalid. Today only BooleanWidget converts error itself. No other widget ever shows a validation error.
  5. required is passed, but controls expect isRequired. Text, date, datetime and radio-based widgets never show the required state.
  6. onBlur is not wired, so TanStack Form's touched state and blur validation never run. (Document and type the form widget contract #97 adds this.)
  7. 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.
  8. The caller's label is overwritten. label: fieldProps.title (Field.tsx#L185) replaces the label the caller passed.
  9. No disabled / readOnly. Schema readonly and form-level disabled states are not mapped to anything.
  10. 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

  1. An unknown widget name falls back to the default widget instead of continuing the lookup. Field.tsx#L79 returns getWidgetDefault(), 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 a TextField. Today that still affects the File type's file field. Image's image field only escapes because of B2.

  2. 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 named image matches the image widget, and getWidgetByType('boolean') only works because boolean is (wrongly) registered under the widget category (widgets.ts#L30).

  3. choices / vocabulary lookup returns nothing. config.widgets.choices is read (Field.tsx#L152) but never registered, so select and vocabulary fields render as a text input.

  4. Core Plone widgets are missing from the registry:

    • textarea: plone.restapi sends it for description, and Quanta already has TextAreaField;
    • 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/ByVocabulary are all React.ComponentType<any>, so registerWidget accepts 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's FieldProps). Document and type the form widget contract #97 adds FormWidgetProps<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

  1. The form normalizes and the widget renders. Converting errors, mapping required, resolving labels and applying defaults all happens once, in the dispatcher. Widgets never see error, title or default.
  2. Widgets are controlled. They render value and report changes through onChange. They don't keep a parallel source of truth, and they don't read form atoms or route loaders.
  3. 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.
  4. 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-level onChangeSideEffects (already used by block settings), not through extra onChange arguments.
  5. Controls stay in @plone/quanta, widgets live in @plone/cmsui. Every registered entry is an adapter (or a native widget) typed FormWidget<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. BooleanWidget is the example: a few lines mapping value → isSelected, but an adapter, so it stays in cmsui next to ObjectBrowserWidget.
    • Quanta components are named after what they are. AlignWidget, SizeWidget and WidthWidget are controls (typed Omit<RadioGroupProps, 'children'>) named like widgets, which is what makes them look like peers of BooleanWidget. 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).
  6. 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)

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

  1. sneridagh commented on Oct 8, 2026

    @sneridagh
    MemberAuthor

    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.

  2. sneridagh commented on Oct 8, 2026

    @sneridagh
    MemberAuthor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions