Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/aurora/news/+widget-contract-tests.documentation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Documented testing widgets against the contract, and the compile-time check of registered widgets. @sneridagh
4 changes: 4 additions & 0 deletions docs/conceptual-guides/form-fields-controls-and-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ For example, the align widget of a block schema reads its `actions` this way.
{ref}`core-widgets-label` lists the widget options of each core widget.
Widget props from the field's tagged values, `frontendOptions.widgetProps`, are applied last.

In Plone Aurora, the widget registry checks this contract when it is type-checked.
`@plone/cmsui` declares it in the `WidgetPropsMap` of `@plone/types`, so registering a widget that doesn't take the contract's props is a type error.
To test a widget's behavior against the contract, see {ref}`test-a-widget-label`.

The contract is about values.
The form does not need to know whether a widget uses an HTML input, a React Aria component, a modal picker, or several controls together.
It passes the current value in and receives the next value back.
Expand Down
47 changes: 47 additions & 0 deletions docs/reference/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,50 @@ Its value is an object with the query's criteria, and its sorting and size.
Edits the recurrence of an event in a dialog.
Its value is a recurrence rule in the iCalendar format (RFC 5545), such as `RRULE:FREQ=WEEKLY;BYDAY=MO`, or `null` when the event doesn't repeat.
It reads the event's `start` and `end` from the form, to offer the right days and end date.

(test-a-widget-label)=

## Test your widget against the contract

`@plone/cmsui` ships a test helper that checks a widget against the widget contract.
Run it for each widget you register.
It checks that the widget:

- renders its label and description;
- marks a required field;
- shows its validation error when invalid, and only then;
- renders without a value;
- ignores the widget options it doesn't know;
- reports a change with `onChange(value)`, if you tell it how to make one.

```ts
import { fireEvent, screen } from '@testing-library/react';
import { describeWidgetContract } from '@plone/cmsui/testing/widgetContract';
import { PhoneWidget } from './PhoneWidget';

describeWidgetContract('PhoneWidget', PhoneWidget, {
// A valid value, as the content API stores it.
value: '+49 89 1234567',
// Optional: how an editor changes the value, and the value to expect.
change: {
perform: () =>
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '+49 89 7654321' },
}),
expected: '+49 89 7654321',
},
});
```

The options also take extra `props` for the widget, a `wrapper` for the providers it needs, and `skip`, to skip a check the widget doesn't pass yet.
`skip` takes a reason for each check, so the test report shows what is missing.

```ts
describeWidgetContract('MapWidget', MapWidget, {
value: { lat: 48.14, lng: 11.58 },
skip: { required: 'The map has no required state yet.' },
});
```

The core widgets run the same checks, in {file}`packages/cmsui/config/widgets.contract.test.tsx`.

14 changes: 14 additions & 0 deletions docs/upgrade-guide/plone-aurora.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,20 @@ If your add-on renders `ContentForm`, pass it the `path` of the edited object, o
+ const { path } = useWidgetContext();
```

#### Registered widgets are type-checked against the contract

```{versionchanged} 1.0.0-alpha.20
The widget registry types the widgets it accepts with the widget contract, `FormWidgetProps`, once `@plone/cmsui` is installed.
```

```{versionadded} 1.0.0-alpha.20
`WidgetPropsMap` and `RegisteredWidgetProps` in `@plone/types`, and the `describeWidgetContract` test helper in `@plone/cmsui/testing/widgetContract`.
```

If your add-on registers a widget whose props don't follow the widget contract, the type check now fails.
Type the widget with `FormWidgetProps`, and adapt the props it reads, as described in the previous steps.
Test it with `describeWidgetContract`, see {ref}`test-a-widget-label`.

#### Validators receive `t`

```{versionadded} 1.0.0-alpha.20
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,10 @@ const meta = {
},
tags: ['autodocs'],
args: {
name: 'relatedItems',
label: 'Related items',
description: 'Select content to feature on the page.',
title: 'Select Content',
onChange: () => {},
},
} satisfies Meta<ObjectBrowserWidgetStoryProps>;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Brain } from '@plone/types';
import type { Brain, FormWidgetProps } from '@plone/types';
import type { TextFieldProps as QuantaTextFieldProps } from '@plone/quanta';
import {
Description,
Expand Down Expand Up @@ -72,20 +72,38 @@ export function ObjectBrowserWidgetComponent(props: ObjectBrowserWidgetProps) {
);
}

export function ObjectBrowserWidget(
props: ObjectBrowserWidgetProps & { value?: Brain[] | null },
) {
type ObjectBrowserFormWidgetProps = FormWidgetProps<Partial<Brain>[] | null> &
Pick<UseObjectBrowserConfig, 'mode' | 'selectedItemAttrs'>;

/**
* Picks content with the object browser. Its value is a list of the
* selected items, each with the attributes in `selectedItemAttrs` (by
* default `@id`, `title`, `description`, `@type` and `UID`).
*/
export function ObjectBrowserWidget(props: ObjectBrowserFormWidgetProps) {
// The browser starts from the object the form is about (or the container
// a new object is added to), whichever route the form is in.
const { path } = useWidgetContext();
const { label, description, errorMessage, value, defaultValue, ...rest } =
props;
const {
label,
description,
errorMessage,
value,
defaultValue,
onChange,
mode,
selectedItemAttrs,
widgetOptions,
} = props;
return (
<ObjectBrowserProvider
config={{
...rest,
mode,
selectedItemAttrs,
widgetOptions: widgetOptions as UseObjectBrowserConfig['widgetOptions'],
onChange,
// The selection follows the field value.
value: value ?? defaultValue ?? [],
value: (value ?? defaultValue ?? []) as Brain[],
title: label,
initialPath: path,
}}
Expand Down
4 changes: 2 additions & 2 deletions packages/cmsui/components/PickerWidgets/PickerWidgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type WidthPickerProps,
} from '@plone/quanta';

type PickerWidgetProps<P> = FormWidgetProps<string | null> &
type PickerWidgetProps<P> = FormWidgetProps<string> &
Pick<P, Extract<keyof P, 'actions' | 'actionsInfoMap'>>;

/**
Expand All @@ -29,7 +29,7 @@ const toPickerProps = ({
invalid,
errorMessage,
className,
}: FormWidgetProps<string | null>) => ({
}: FormWidgetProps<string>) => ({
name,
value: value ?? defaultValue ?? null,
onChange: (next: string) => onChange(next),
Expand Down
76 changes: 76 additions & 0 deletions packages/cmsui/config/widgets.contract.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { fireEvent, screen } from '@testing-library/react';
import { describeWidgetContract } from '../testing/widgetContract';
import { TextWidget } from '../components/TextWidget/TextWidget';
import { TextareaWidget } from '../components/TextareaWidget/TextareaWidget';
import { BooleanWidget } from '../components/BooleanWidget/BooleanWidget';
import { DateWidget } from '../components/DateWidget/DateWidget';
import { DateTimeWidget } from '../components/DateTimeWidget/DateTimeWidget';
import {
AlignWidget,
SizeWidget,
WidthWidget,
} from '../components/PickerWidgets/PickerWidgets';

// Every core widget runs the contract tests.

describeWidgetContract('TextWidget', TextWidget, {
value: 'Hello',
change: {
perform: () =>
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Hello world' },
}),
expected: 'Hello world',
},
});

describeWidgetContract('TextareaWidget', TextareaWidget, {
value: 'First line',
change: {
perform: () =>
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'First line\nSecond line' },
}),
expected: 'First line\nSecond line',
},
});

describeWidgetContract('BooleanWidget', BooleanWidget, {
value: false,
change: {
perform: () => fireEvent.click(screen.getByRole('checkbox')),
expected: true,
},
});

describeWidgetContract('DateWidget', DateWidget, { value: '2026-10-09' });

describeWidgetContract('DateTimeWidget', DateTimeWidget, {
value: '2026-10-09T10:00:00+00:00',
});

describeWidgetContract('AlignWidget', AlignWidget, {
value: 'left',
change: {
perform: () =>
fireEvent.click(screen.getByRole('radio', { name: 'Right' })),
expected: 'right',
},
});

describeWidgetContract('SizeWidget', SizeWidget, {
value: 's',
change: {
perform: () =>
fireEvent.click(screen.getByRole('radio', { name: 'Large' })),
expected: 'l',
},
});

describeWidgetContract('WidthWidget', WidthWidget, {
value: 'default',
change: {
perform: () => fireEvent.click(screen.getByRole('radio', { name: 'Full' })),
expected: 'full',
},
});
49 changes: 49 additions & 0 deletions packages/cmsui/config/widgets.types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import config from '@plone/registry';
import type { FormWidgetProps } from '@plone/types';

// The registry only accepts widgets that follow the widget contract, once
// `@plone/cmsui` declares it (`WidgetPropsMap` in `index.ts`). The type check
// is the test: these registrations must, or must not, compile.

const ContractWidget = ({
label,
value,
onChange,
}: FormWidgetProps<string>) => (
<input
aria-label={label}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
/>
);

// A widget with Volto's props: `id` instead of `name`, and `onChange(id, value)`.
const VoltoStyleWidget = ({
id,
onChange,
}: {
id: string;
onChange: (id: string, value: string) => void;
}) => <input onChange={(event) => onChange(id, event.target.value)} />;

describe('widget registry types', () => {
it('accepts widgets that follow the widget contract', () => {
config.registerWidget({
key: 'widget',
definition: { contract: ContractWidget },
});
expect(config.getWidget('contract', 'widget')).toBe(ContractWidget);
});

it('rejects widgets that do not', () => {
config.registerWidget({
key: 'widget',
// @ts-expect-error A widget must take the widget contract's props.
definition: { volto: VoltoStyleWidget },
});
// @ts-expect-error The default widget too.
config.registerDefaultWidget(VoltoStyleWidget);
expect(true).toBe(true);
});
});
8 changes: 7 additions & 1 deletion packages/cmsui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import installControlpanels from './config/controlpanels';
import installRoutes from './config/routes';
import installSlots from './config/slots';
import installValidators from './config/validators';
import type { BlockConfigBase } from '@plone/types';
import type { BlockConfigBase, FormWidgetProps } from '@plone/types';

declare module '@plone/types' {
export interface BlocksConfigData {
__somersault__: BlockConfigBase;
}

// The widgets registered for the CMS UI forms follow the widget contract,
// so the type checker rejects a widget that doesn't take its props.
export interface WidgetPropsMap {
props: FormWidgetProps<any>;
}
}

export default function install(config: ConfigType) {
Expand Down
1 change: 1 addition & 0 deletions packages/cmsui/news/+typed-widget-registry.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The widget registry checks registered widgets against the widget contract at compile time (`@plone/cmsui` declares `FormWidgetProps` in `WidgetPropsMap`). Added the `describeWidgetContract` test helper (`@plone/cmsui/testing/widgetContract`), which every core widget runs. `ObjectBrowserWidget` is typed with the widget contract. @sneridagh
Loading
Loading