From fe397de923e4f5d79e08f59ead19e50f82cabd42 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Fri, 9 Oct 2026 19:19:09 +0200 Subject: [PATCH] Type-check registered widgets and test them against the contract The widget registry typed its slots as React.ComponentType, 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 --- .../news/+widget-contract-tests.documentation | 1 + .../form-fields-controls-and-widgets.md | 4 + docs/reference/widgets.md | 47 ++++++ docs/upgrade-guide/plone-aurora.md | 14 ++ .../ObjectBrowserWidget.stories.tsx | 3 +- .../ObjectBrowserWidget.tsx | 34 +++- .../PickerWidgets/PickerWidgets.tsx | 4 +- .../cmsui/config/widgets.contract.test.tsx | 76 +++++++++ packages/cmsui/config/widgets.types.test.tsx | 49 ++++++ packages/cmsui/index.ts | 8 +- .../cmsui/news/+typed-widget-registry.feature | 1 + packages/cmsui/testing/widgetContract.tsx | 153 ++++++++++++++++++ .../news/+registered-widget-props.feature | 1 + packages/registry/src/index.ts | 3 +- packages/types/news/+widget-props-map.feature | 1 + packages/types/src/config/Widgets.d.ts | 44 ++++- 16 files changed, 423 insertions(+), 20 deletions(-) create mode 100644 apps/aurora/news/+widget-contract-tests.documentation create mode 100644 packages/cmsui/config/widgets.contract.test.tsx create mode 100644 packages/cmsui/config/widgets.types.test.tsx create mode 100644 packages/cmsui/news/+typed-widget-registry.feature create mode 100644 packages/cmsui/testing/widgetContract.tsx create mode 100644 packages/registry/news/+registered-widget-props.feature create mode 100644 packages/types/news/+widget-props-map.feature diff --git a/apps/aurora/news/+widget-contract-tests.documentation b/apps/aurora/news/+widget-contract-tests.documentation new file mode 100644 index 000000000..0a02263ae --- /dev/null +++ b/apps/aurora/news/+widget-contract-tests.documentation @@ -0,0 +1 @@ +Documented testing widgets against the contract, and the compile-time check of registered widgets. @sneridagh diff --git a/docs/conceptual-guides/form-fields-controls-and-widgets.md b/docs/conceptual-guides/form-fields-controls-and-widgets.md index 0e0a0131f..cb096af9c 100644 --- a/docs/conceptual-guides/form-fields-controls-and-widgets.md +++ b/docs/conceptual-guides/form-fields-controls-and-widgets.md @@ -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. diff --git a/docs/reference/widgets.md b/docs/reference/widgets.md index f9155d58e..cc47e6ba8 100644 --- a/docs/reference/widgets.md +++ b/docs/reference/widgets.md @@ -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`. + diff --git a/docs/upgrade-guide/plone-aurora.md b/docs/upgrade-guide/plone-aurora.md index c2ecdcd96..75ac10ff3 100644 --- a/docs/upgrade-guide/plone-aurora.md +++ b/docs/upgrade-guide/plone-aurora.md @@ -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 diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx index 563efe4f2..4b1535c59 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx @@ -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; diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.tsx index 88984a372..dfe6f6e50 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.tsx @@ -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, @@ -72,20 +72,38 @@ export function ObjectBrowserWidgetComponent(props: ObjectBrowserWidgetProps) { ); } -export function ObjectBrowserWidget( - props: ObjectBrowserWidgetProps & { value?: Brain[] | null }, -) { +type ObjectBrowserFormWidgetProps = FormWidgetProps[] | null> & + Pick; + +/** + * 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 ( = FormWidgetProps & +type PickerWidgetProps

= FormWidgetProps & Pick>; /** @@ -29,7 +29,7 @@ const toPickerProps = ({ invalid, errorMessage, className, -}: FormWidgetProps) => ({ +}: FormWidgetProps) => ({ name, value: value ?? defaultValue ?? null, onChange: (next: string) => onChange(next), diff --git a/packages/cmsui/config/widgets.contract.test.tsx b/packages/cmsui/config/widgets.contract.test.tsx new file mode 100644 index 000000000..9bc82cab2 --- /dev/null +++ b/packages/cmsui/config/widgets.contract.test.tsx @@ -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', + }, +}); diff --git a/packages/cmsui/config/widgets.types.test.tsx b/packages/cmsui/config/widgets.types.test.tsx new file mode 100644 index 000000000..9eff4b59c --- /dev/null +++ b/packages/cmsui/config/widgets.types.test.tsx @@ -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) => ( + 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; +}) => 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); + }); +}); diff --git a/packages/cmsui/index.ts b/packages/cmsui/index.ts index 7fe5029e5..7e8308ef3 100644 --- a/packages/cmsui/index.ts +++ b/packages/cmsui/index.ts @@ -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; + } } export default function install(config: ConfigType) { diff --git a/packages/cmsui/news/+typed-widget-registry.feature b/packages/cmsui/news/+typed-widget-registry.feature new file mode 100644 index 000000000..39e68d5ff --- /dev/null +++ b/packages/cmsui/news/+typed-widget-registry.feature @@ -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 diff --git a/packages/cmsui/testing/widgetContract.tsx b/packages/cmsui/testing/widgetContract.tsx new file mode 100644 index 000000000..b4f5e0681 --- /dev/null +++ b/packages/cmsui/testing/widgetContract.tsx @@ -0,0 +1,153 @@ +import { act, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ComponentType, ReactNode } from 'react'; +import type { FormWidgetProps } from '@plone/types'; + +/** The checks of `describeWidgetContract`. */ +export type WidgetContractCheck = + | 'label' + | 'description' + | 'required' + | 'invalid' + | 'valid' + | 'empty' + | 'widgetOptions' + | 'change'; + +export type WidgetContractOptions = { + /** A valid value of the field, as the content API stores it. */ + value: T; + /** + * Changes the value in the rendered widget, like an editor would, and the + * value the widget must then report with `onChange`. + */ + change?: { + perform: (container: HTMLElement) => unknown; + expected: T; + }; + /** Other props the widget needs, such as its widget options. */ + props?: Partial> & Record; + /** Wraps the widget, for example in a router or other providers. */ + wrapper?: ComponentType<{ children: ReactNode }>; + /** + * Checks the widget does not pass yet, each with the reason. They are + * reported as skipped, so the gap stays visible. + */ + skip?: Partial>; +}; + +const LABEL = 'Contract field'; +const DESCRIPTION = 'Contract field description'; +const ERROR = 'Contract field error'; + +/** + * Tests that a widget follows the widget contract (`FormWidgetProps`): it + * renders its label and description, marks a required field, shows its + * validation error only when invalid, renders without a value, ignores the + * widget options it does not know, and reports changes with + * `onChange(value)`. + * + * Use it for every widget you register: + * + * ```ts + * import { describeWidgetContract } from '@plone/cmsui/testing/widgetContract'; + * + * describeWidgetContract('PhoneWidget', PhoneWidget, { + * value: '+49 89 1234567', + * change: { + * perform: () => userEvent.type(screen.getByRole('textbox'), '8'), + * expected: '+49 89 12345678', + * }, + * }); + * ``` + */ +export function describeWidgetContract( + name: string, + Widget: ComponentType>, + // The value type comes from the widget, not from the example value. + options: WidgetContractOptions>, +) { + const renderWidget = (props: Record = {}) => + render( + >)} + {...(props as Partial>)} + />, + { wrapper: options.wrapper }, + ); + + const check = ( + id: WidgetContractCheck, + title: string, + test: () => void | Promise, + ) => { + const reason = options.skip?.[id]; + if (reason) it.skip(`${title} (skipped: ${reason})`, test); + else it(title, test); + }; + + describe(`${name} follows the widget contract`, () => { + check('label', 'renders its label', () => { + renderWidget(); + expect(screen.getAllByText(LABEL).length).toBeGreaterThan(0); + }); + + check('description', 'renders its description', () => { + renderWidget({ description: DESCRIPTION }); + expect(screen.getByText(DESCRIPTION)).toBeTruthy(); + }); + + check('required', 'marks a required field', () => { + const { container } = renderWidget({ required: true }); + expect( + container.querySelector('[required], [aria-required="true"]'), + ).not.toBeNull(); + }); + + check('invalid', 'shows its validation error when invalid', () => { + const { container } = renderWidget({ + invalid: true, + errorMessage: ERROR, + }); + expect(screen.getByText(ERROR)).toBeTruthy(); + expect( + container.querySelector('[aria-invalid="true"], [data-invalid]'), + ).not.toBeNull(); + }); + + check('valid', 'does not show an error message when valid', () => { + renderWidget({ invalid: false, errorMessage: ERROR }); + expect(screen.queryByText(ERROR)).toBeNull(); + }); + + check('empty', 'renders without a value', () => { + renderWidget({ value: null }); + renderWidget({ value: undefined }); + expect(screen.getAllByText(LABEL).length).toBeGreaterThan(0); + }); + + check('widgetOptions', 'ignores widget options it does not know', () => { + renderWidget({ + unknownWidgetOption: 'ignored', + schema: { title: LABEL, unknownWidgetOption: 'ignored' }, + }); + expect(screen.getAllByText(LABEL).length).toBeGreaterThan(0); + }); + + if (options.change) { + const { perform, expected } = options.change; + check('change', 'reports a change with onChange(value)', async () => { + const onChange = vi.fn(); + const { container } = renderWidget({ onChange }); + await act(async () => { + await perform(container); + }); + expect(onChange).toHaveBeenLastCalledWith(expected); + }); + } + }); +} diff --git a/packages/registry/news/+registered-widget-props.feature b/packages/registry/news/+registered-widget-props.feature new file mode 100644 index 000000000..c2ac7f0e5 --- /dev/null +++ b/packages/registry/news/+registered-widget-props.feature @@ -0,0 +1 @@ +The default widget is typed with the props the app declares in `WidgetPropsMap`. @sneridagh diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index a25830371..a4e55628b 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -13,6 +13,7 @@ import type { UtilitiesConfig, ViewsConfig, WidgetsConfig, + RegisteredWidgetProps, ReactRouterRouteEntry, WidgetKey, UtilityTypeMap, @@ -594,7 +595,7 @@ class Config { * @param component - The default widget component to register. * */ - registerDefaultWidget(component: React.ComponentType) { + registerDefaultWidget(component: React.ComponentType) { this._data.widgets.default = component; } diff --git a/packages/types/news/+widget-props-map.feature b/packages/types/news/+widget-props-map.feature new file mode 100644 index 000000000..d88943053 --- /dev/null +++ b/packages/types/news/+widget-props-map.feature @@ -0,0 +1 @@ +Added `WidgetPropsMap` and `RegisteredWidgetProps`: an app declares the props its registered widgets take, and the widget registry types its widget slots with them. Without a declaration, any props are accepted, as before. Added `pattern_options` to `WidgetOptions`. @sneridagh diff --git a/packages/types/src/config/Widgets.d.ts b/packages/types/src/config/Widgets.d.ts index 9937738f9..b89aa0a72 100644 --- a/packages/types/src/config/Widgets.d.ts +++ b/packages/types/src/config/Widgets.d.ts @@ -10,6 +10,11 @@ export type WidgetOptions = Record & { widgetProps?: Record; }; vocabulary?: WidgetVocabulary; + /** + * Options of relation fields, e.g. the content types the object browser + * can select (`selectableTypes`). + */ + pattern_options?: Record; }; /** @@ -63,6 +68,31 @@ export interface FormWidgetProps { schema?: FieldSchema; } +/** + * The props of the widgets in the widget registry (the `default`, `id`, + * `widget`, `vocabulary`, `factory`, `choices` and `type` slots). + * + * Empty by default, so a registered widget can take any props. An app + * declares its widget contract here, to check every registered widget + * against it at compile time. Plone Aurora's CMS UI declares + * `FormWidgetProps`: + * + * ```ts + * declare module '@plone/types' { + * interface WidgetPropsMap { + * props: FormWidgetProps; + * } + * } + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WidgetPropsMap {} + +/** The props registered widgets take: see `WidgetPropsMap`. */ +export type RegisteredWidgetProps = WidgetPropsMap extends { props: infer P } + ? P + : any; + export type FormWidget = React.ComponentType< FormWidgetProps >; @@ -80,7 +110,7 @@ export type WidgetIdsTypes = export type WidgetsConfigById< K extends WidgetIdsTypes = WidgetIdsTypes, - P = any, + P = RegisteredWidgetProps, > = Partial<{ [id in K]: React.ComponentType

; }> & { @@ -125,7 +155,7 @@ export type WidgetByWidgetTypes = export type WidgetsConfigByWidget< K extends WidgetByWidgetTypes = WidgetByWidgetTypes, - P = any, + P = RegisteredWidgetProps, > = Partial<{ [widgetType in K]: React.ComponentType

; }>; @@ -135,7 +165,7 @@ export type WidgetVocabularyTypes = export type WidgetsConfigByVocabulary< K extends WidgetVocabularyTypes = WidgetVocabularyTypes, - P = any, + P = RegisteredWidgetProps, > = Partial<{ [vocabularyName in K]: React.ComponentType

; }>; @@ -145,7 +175,7 @@ export type WidgetFactortTypes = export type WidgetsConfigByFactory< K extends WidgetFactortTypes = WidgetFactortTypes, - P = any, + P = RegisteredWidgetProps, > = Partial<{ [factoryName in K]: React.ComponentType

; }>; @@ -164,7 +194,7 @@ export type WidgetByTypeTypes = export type WidgetsConfigByType< K extends WidgetByTypeTypes = WidgetByTypeTypes, - P = any, + P = RegisteredWidgetProps, > = Partial<{ [widgetType in K]: React.ComponentType

; }>; @@ -228,12 +258,12 @@ export interface WidgetsConfigViews

{ } export interface WidgetsConfig { - default: React.ComponentType; + default: React.ComponentType; id: WidgetsConfigById; widget: WidgetsConfigByWidget; vocabulary: WidgetsConfigByVocabulary; factory: WidgetsConfigByFactory; - choices: React.ComponentType; + choices: React.ComponentType; type: WidgetsConfigByType; views: WidgetsConfigViews; }