Skip to content

Type-check registered widgets and test them against the contract - #262

Open
sneridagh wants to merge 4 commits into
feat/widget-contextfrom
feat/typed-widget-registry
Open

sneridagh wants to merge 4 commits into
feat/widget-contextfrom
feat/typed-widget-registry

Conversation

@sneridagh

Copy link
Copy Markdown
Member

#243 step 6. Stacked on #261. Refs #243 (step 7, the missing widgets, still remains).

Typed widget registry, opt-in per app

The widget registry typed its slots as React.ComponentType<any>, so any component could be registered, whatever props it took. Simply typing them with FormWidgetProps would break Volto add-ons in TypeScript that register Volto-style widgets (id, onChange(id, value)), since @plone/types and @plone/registry are shared with Volto. So the contract is declared by the app:

  • @plone/types adds an empty WidgetPropsMap and RegisteredWidgetProps. The editing slots (default, id, widget, vocabulary, factory, choices, type) are typed with it, and with any props when nothing is declared. Display widgets (views) are unchanged.
  • @plone/cmsui declares FormWidgetProps in it (next to its existing @plone/types augmentation). In Aurora, registering a widget that doesn't take the contract's props is a type error, in registerWidget, in registerDefaultWidget, and in direct config.widgets assignments.
  • Volto stays as it is: a Volto-style registration type-checks against @plone/types without cmsui (checked with a scratch file), and widgets.types.test.tsx checks the Aurora side with @ts-expect-error.

The check found one real mismatch: ObjectBrowserWidget expected widgetOptions.pattern_options, which the contract's WidgetOptions didn't describe. WidgetOptions gains pattern_options (plone.restapi sends it for relation fields), and the widget is now typed with the contract. It passes its options explicitly instead of spreading all props into the browser's config. All other registered widgets already conformed.

Contract test helper

describeWidgetContract(name, Widget, options) (@plone/cmsui/testing/widgetContract) tests a widget's behavior against the contract:

  • it renders its label and description;
  • it marks a required field (required or aria-required);
  • it shows its validation error when invalid, with aria-invalid or data-invalid, and doesn't show it when valid;
  • it renders with null and undefined values;
  • it ignores widget options it doesn't know;
  • optionally, it reports a change with onChange(value).

skip takes a reason per check, so a known gap shows up as a skipped test rather than going unnoticed.

  • Every core widget runs it (config/widgets.contract.test.tsx): text, textarea, boolean, date, date-time, align, size, width. 64 tests, no skips.
  • It catches the gaps Define and enforce the form widget contract (forms ↔ widgets) #243 is about: run against the raw Quanta TextField (the old default widget), it fails "marks a required field" and "shows its validation error".
  • A type fix it surfaced: the picker adapters are now typed as string widgets, since a picker never emits null.

Docs

  • "Core widgets" reference: a "Test your widget against the contract" section.
  • Guide: the registry checks the contract at type-check time.
  • Upgrade guide: a versionchanged / versionadded step for add-ons whose widgets don't follow the contract.

Tests

  • Unit: cmsui 308 tests pass (including the 64 contract tests and the registry type tests); registry 127.
  • Type checks clean for types, registry, cmsui, blocks, plate, quanta and layout. Lint, the docs build and the optimizeDeps audit are clean.
  • Full acceptance suite (local, dev mode): 211 passed, 0 failed, which covers the object browser's explicit option mapping.

The widget registry typed its slots as React.ComponentType<any>, so any
component could be registered, whatever props it took. @plone/types now
has a WidgetPropsMap that an app augments with the props its widgets
take; the registry types its widget slots with it, and with any props
when nothing is declared, so Volto and its add-ons are unaffected.
@plone/cmsui declares FormWidgetProps, so in Plone Aurora registering a
widget that doesn't take the contract's props is a type error.

The check found one: ObjectBrowserWidget read a widgetOptions shape the
contract didn't describe. WidgetOptions gains pattern_options, and the
widget is typed with the contract.

describeWidgetContract (@plone/cmsui/testing/widgetContract) tests a
widget's behavior against the contract: label, description, required,
validation error only when invalid, no value, unknown widget options,
and onChange(value). Every core widget runs it; the raw Quanta
TextField, the old default widget, fails the required and invalid
checks. The picker adapters are typed as string widgets, since a picker
never emits null.

Refs #243
…yped-widget-registry

* origin/feat/widget-context:
  Read the object browser breadcrumbs from the middleware
@sneridagh

Copy link
Copy Markdown
Member Author

LGTM

…yped-widget-registry

* origin/feat/widget-context:
  Restore files the pre-commit hook reformatted
  Validate schema-driven forms (#256)
  Move the recurrence modal off TanStack Form (#254)
  Move the forms onto the helpers form store (#253)
  Add a Jotai-native form layer to helpers (#252)
  Render every schema form through one field renderer (#251)
  Document and type the form widget contract (#97)
  Match utility dependencies exactly in the registry (#259)
…yped-widget-registry

* origin/feat/widget-context:
  Add the components changelog entry for the picker rename
@sneridagh
sneridagh added this pull request to stack #270 October 10, 2026 22:48

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant