diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 9180d5a3..6f2be906 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -912,6 +912,11 @@ export const FileUpload: Story = { }); await expect(canvas.queryByText('.pdf')).toBeVisible(); await userEvent.click(canvas.getByText('.jpg')); + // wait for dropdown to close and selected option to be visible; + await waitFor(async () => { + await expect(canvas.queryByText('.pdf')).toBeNull(); + }); + await expect(canvas.queryByText('.jpg')).toBeVisible(); }); await step('Submit configuration', async () => { @@ -1387,6 +1392,224 @@ export const Radio: Story = { }, }; +export const Select: Story = { + render: Template, + name: 'type: select', + + args: { + component: { + id: 'wqimsadk', + type: 'select', + key: 'select', + label: 'A select field', + openForms: { + dataSrc: 'manual', + translations: {}, + }, + dataSrc: 'values', + data: {values: []}, + defaultValue: '', + }, + + builderInfo: { + title: 'Select', + icon: 'th-list', + group: 'basic', + weight: 70, + schema: {}, + }, + }, + + play: async ({canvasElement, step, args}) => { + const canvas = within(canvasElement); + const editForm = within(canvas.getByTestId('componentEditForm')); + const preview = within(canvas.getByTestId('componentPreview')); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A select field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aSelectField'); + }); + await expect(canvas.getByLabelText('Description')).toHaveValue(''); + await expect(canvas.getByLabelText('Tooltip')).toHaveValue(''); + await expect(canvas.getByLabelText('Show in summary')).toBeChecked(); + await expect(canvas.getByLabelText('Show in email')).not.toBeChecked(); + await expect(canvas.getByLabelText('Show in PDF')).toBeChecked(); + + // ensure that changing fields in the edit form properly update the preview + + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label'); + expect(await preview.findByText('Updated preview label')); + + // Ensure that the manually entered key is kept instead of derived from the label, + // even when key/label components are not mounted. + const keyInput = canvas.getByLabelText('Property Name'); + // fireEvent is deliberate, as userEvent.clear + userEvent.type briefly makes the field + // not have any value, which triggers the generate-key-from-label behaviour. + fireEvent.change(keyInput, {target: {value: 'customKey'}}); + await userEvent.click(canvas.getByRole('tab', {name: 'Basic'})); + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Other label', {delay: 50}); + await expect(canvas.getByLabelText('Property Name')).toHaveDisplayValue('customKey'); + + await step('Set up manual options', async () => { + // enter some possible options + const firstOptionLabelInput = canvas.getByLabelText('Option label'); + expect(firstOptionLabelInput).toHaveDisplayValue(''); + await userEvent.type(firstOptionLabelInput, 'Option label 1'); + const firstOptionValue = canvas.getByLabelText('Option value'); + await waitFor(() => expect(firstOptionValue).toHaveDisplayValue('optionLabel1')); + + // add a second option + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const optionLabels = canvas.queryAllByLabelText('Option label'); + const optionValues = canvas.queryAllByLabelText('Option value'); + expect(optionLabels).toHaveLength(2); + expect(optionValues).toHaveLength(2); + await userEvent.type(optionValues[1], 'manualValue'); + await userEvent.type(optionLabels[1], 'Second option'); + + const previewSearchInput = preview.getByLabelText('Other label'); + previewSearchInput.focus(); + await userEvent.keyboard('[ArrowDown]'); + await expect(await preview.findByText('Second option')).toBeVisible(); + await waitFor(() => { + expect(preview.queryByRole('listbox')).toBeNull(); + }); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalledWith({ + id: 'wqimsadk', + type: 'select', + // basic tab + label: 'Other label', + key: 'customKey', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + dataSrc: 'values', + data: { + values: [ + { + value: 'optionLabel1', + label: 'Option label 1', + }, + { + value: 'manualValue', + label: 'Second option', + openForms: {translations: {}}, + }, + ], + }, + openForms: { + dataSrc: 'manual', + translations: {}, + }, + defaultValue: '', + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + translatedErrors: { + nl: {required: ''}, + }, + // registration tab + registration: { + attribute: '', + }, + }); + // @ts-expect-error + args.onSubmit.mockClear(); + }); + + await step('Option labels are translatable', async () => { + await userEvent.click(canvas.getByRole('tab', {name: 'Translations'})); + + // check that the option labels are in the translations table + expect(await editForm.findByText('Option label 1')).toBeVisible(); + expect(await editForm.findByText('Second option')).toBeVisible(); + }); + + await step('Set up itemsExpression for options', async () => { + await userEvent.click(canvas.getByRole('tab', {name: 'Basic'})); + + canvas.getByLabelText('Data source').focus(); + await userEvent.keyboard('[ArrowDown]'); + await userEvent.click(await canvas.findByText('From variable')); + const itemsExpressionInput = canvas.getByLabelText('Items expression'); + await userEvent.clear(itemsExpressionInput); + // { needs to be escaped: https://github.com/testing-library/user-event/issues/584 + const expression = '{"var": "someVar"}'.replace(/[{[]/g, '$&$&'); + await userEvent.type(itemsExpressionInput, expression); + + await expect(editForm.queryByLabelText('Default value')).toBeNull(); + + const previewSearchInput = preview.getByLabelText('Other label'); + previewSearchInput.focus(); + await userEvent.keyboard('[ArrowDown]'); + await expect(await preview.findByText(/"someVar"/)).toBeVisible(); + await userEvent.keyboard('[Esc]'); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalledWith({ + id: 'wqimsadk', + type: 'select', + // basic tab + label: 'Other label', + key: 'customKey', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + dataSrc: 'values', + data: {}, + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'someVar'}, + translations: {}, + }, + defaultValue: '', + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + translatedErrors: { + nl: {required: ''}, + }, + // registration tab + registration: { + attribute: '', + }, + }); + // @ts-expect-error + args.onSubmit.mockClear(); + }); + }, +}; + export const BSN: Story = { render: Template, name: 'type: bsn', diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index f22459cd..ab6c32ad 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -1,6 +1,6 @@ import {expect} from '@storybook/jest'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; -import {fireEvent, userEvent, within} from '@storybook/testing-library'; +import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; import ComponentPreview from './ComponentPreview'; @@ -617,7 +617,7 @@ export const File: Story = { }; export const SelectBoxes: Story = { - name: 'Selectboxes manual values', + name: 'Selectboxes: manual values', render: Template, args: { @@ -666,7 +666,7 @@ export const SelectBoxes: Story = { }; export const SelectBoxesVariable: Story = { - name: 'Selectboxes variable for values', + name: 'Selectboxes: variable for values', render: Template, args: { @@ -686,7 +686,7 @@ export const SelectBoxesVariable: Story = { }; export const Radio: Story = { - name: 'Radio manual values', + name: 'Radio: manual values', render: Template, args: { @@ -735,7 +735,7 @@ export const Radio: Story = { }; export const RadioVariable: Story = { - name: 'Radio variable for values', + name: 'Radio: variable for values', render: Template, args: { @@ -754,6 +754,171 @@ export const RadioVariable: Story = { }, }; +/** + * A select component with manually specified options. Only a single option may be picked. + */ +export const Select: Story = { + name: 'Select: manual values', + render: Template, + + args: { + component: { + type: 'select', + id: 'select', + key: 'selectPreview', + label: 'Select preview', + description: 'A preview of the select Formio component', + openForms: { + dataSrc: 'manual', + translations: {}, + }, + dataSrc: 'values', + data: { + values: [ + { + value: 'option1', + label: 'Option 1', + }, + { + value: 'option2', + label: 'Option 2', + }, + ], + }, + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Select preview'); + await canvas.findByText('A preview of the select Formio component'); + + // we expect no options to be selected + await expect(canvas.queryByText('Option 1')).toBeNull(); + await expect(canvas.queryByText('Option 2')).toBeNull(); + + // opening the dropdown displays the options + // @ts-expect-error + canvas.getByLabelText(args.component.label).focus(); + await userEvent.keyboard('[ArrowDown]'); + await waitFor(async () => { + await expect(await canvas.findByText('Option 1')).toBeVisible(); + }); + await expect(await canvas.findByText('Option 2')).toBeVisible(); + + // selecting an option by clicking it displays it as selected + await userEvent.click(canvas.getByText('Option 2')); + // wait for the option list to be closed + await waitFor(async () => { + await expect(canvas.queryByRole('listbox')).toBeNull(); + }); + // selected value should still be displayed + await expect(await canvas.findByText('Option 2')).toBeVisible(); + }, +}; + +/** + * A select component with manually specified options. Multiple options may be picked. + */ +export const SelectMultiple: Story = { + name: 'Select: manual values, multiple', + render: Template, + + args: { + component: { + type: 'select', + id: 'select', + key: 'selectPreview', + label: 'Select preview', + description: 'A preview of the select Formio component', + multiple: true, + openForms: { + dataSrc: 'manual', + translations: {}, + }, + dataSrc: 'values', + data: { + values: [ + { + value: 'option1', + label: 'Option 1', + }, + { + value: 'option2', + label: 'Option 2', + }, + { + value: 'option3', + label: 'Option 3', + }, + ], + }, + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Select preview'); + await canvas.findByText('A preview of the select Formio component'); + + // we expect no options to be selected + await expect(canvas.queryByText('Option 1')).toBeNull(); + await expect(canvas.queryByText('Option 2')).toBeNull(); + + // opening the dropdown displays the options, select two of them + // @ts-expect-error + const searchInput = canvas.getByLabelText(args.component.label); + searchInput.focus(); + await userEvent.keyboard('[ArrowDown]'); + await waitFor(async () => { + await userEvent.click(await canvas.findByText('Option 3')); + }); + searchInput.focus(); + await userEvent.keyboard('[ArrowDown]'); + await waitFor(async () => { + await userEvent.click(await canvas.findByText('Option 1')); + }); + // wait for the option list to be closed + await waitFor(async () => { + await expect(canvas.queryByRole('listbox')).toBeNull(); + }); + // selected values should still be displayed + await waitFor(async () => { + await expect(await canvas.findByText('Option 1')).toBeVisible(); + }); + await waitFor(async () => { + await expect(await canvas.findByText('Option 3')).toBeVisible(); + }); + }, +}; + +/** + * A select component with an expression to build the option list in the backend. + */ +export const SelectVariable: Story = { + name: 'Select: variable for values', + render: Template, + + args: { + component: { + type: 'select', + id: 'select', + key: 'selectPreview', + label: 'Select preview', + description: 'A preview of the select Formio component', + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'foo'}, + translations: {}, + }, + }, + }, +}; + export const BSN: Story = { name: 'BSN', render: Template, diff --git a/src/components/ComponentPreview.tsx b/src/components/ComponentPreview.tsx index a9c54ba9..bdb856c8 100644 --- a/src/components/ComponentPreview.tsx +++ b/src/components/ComponentPreview.tsx @@ -37,7 +37,7 @@ const ComponentPreviewWrapper: React.FC = ({ onChange={event => setpreviewMode(event.target.value as PreviewState)} /> -
+
{previewMode === 'editJSON' ? ( = ({ }} >
- {previewMode === 'rich' ? children : } + {previewMode === 'rich' ? ( + children + ) : ( + + )}
)} diff --git a/src/components/JSONPreview.tsx b/src/components/JSONPreview.tsx index c919612b..20aa3614 100644 --- a/src/components/JSONPreview.tsx +++ b/src/components/JSONPreview.tsx @@ -3,8 +3,12 @@ interface JSONPreviewProps { className?: string; } -const JSONPreview: React.FC = ({data, className = ''}) => ( -
+const JSONPreview: React.FC = ({
+  data,
+  className = '',
+  ...props
+}) => (
+  
     {JSON.stringify(data, null, 2)}
   
); diff --git a/src/components/builder/values/values-config.stories.tsx b/src/components/builder/values/values-config.stories.tsx index aac059bb..32d73cc6 100644 --- a/src/components/builder/values/values-config.stories.tsx +++ b/src/components/builder/values/values-config.stories.tsx @@ -1,4 +1,8 @@ -import {RadioComponentSchema, SelectboxesComponentSchema} from '@open-formulieren/types'; +import { + RadioComponentSchema, + SelectComponentSchema, + SelectboxesComponentSchema, +} from '@open-formulieren/types'; import {expect, jest} from '@storybook/jest'; import {Meta, StoryObj} from '@storybook/react'; import {fireEvent, userEvent, waitFor, within} from '@storybook/testing-library'; @@ -33,6 +37,7 @@ export default { type SelectboxesStory = StoryObj>; type RadioStory = StoryObj>; +type SelectStory = StoryObj>; /** * Variant pinned to the `SelectboxesComponentSchema` component type. @@ -221,7 +226,7 @@ export const RadioManual: RadioStory = { }, }; -export const Radioiable: RadioStory = { +export const RadioVariable: RadioStory = { decorators: [withFormik], parameters: { formik: { @@ -234,3 +239,55 @@ export const Radioiable: RadioStory = { }, }, }; + +/** + * Variant pinned to the `SelectComponentSchema` component type. + */ +export const Select: SelectStory = { + decorators: [withFormik], + args: { + name: 'data.values', + }, +}; + +export const SelectManual: SelectStory = { + ...Select, + + parameters: { + formik: { + initialValues: { + openForms: { + dataSrc: 'manual', + }, + data: { + values: [ + { + value: 'a', + label: 'A', + }, + { + value: 'b', + label: 'B', + }, + ], + }, + }, + }, + }, +}; + +export const SelectVariable: SelectStory = { + ...Select, + + parameters: { + formik: { + initialValues: { + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'someVariable'}, + }, + data: {}, + }, + }, + }, +}; diff --git a/src/components/builder/values/values-config.tsx b/src/components/builder/values/values-config.tsx index a5e4e40a..904ed28e 100644 --- a/src/components/builder/values/values-config.tsx +++ b/src/components/builder/values/values-config.tsx @@ -1,11 +1,34 @@ import {useFormikContext} from 'formik'; import {useLayoutEffect} from 'react'; +import {hasOwnProperty} from '@/types'; + import ItemsExpression from './items-expression'; import {SchemaWithDataSrc} from './types'; import ValuesSrc from './values-src'; import ValuesTable, {ValuesTableProps} from './values-table'; +/** + * Check if the dotted `path` exists on `obj`. + * + * @example + * ``` + * isNestedKeySet({my: {path: 'irrelevant'}}, 'my.path') // true + * ``` + */ +function isNestedKeySet(obj: {}, path: string): boolean { + const bits = path.split('.'); + for (const bit of bits) { + // as soon as any node does not have the respective path set, exit, the full deep + // path will then also not be set. + if (!hasOwnProperty(obj, bit)) { + return false; + } + obj = obj[bit] as {}; + } + return true; +} + export interface ValuesConfigProps { name: ValuesTableProps['name']; } @@ -32,13 +55,13 @@ export function ValuesConfig({name}: ValuesConfigPr if (values.openForms.hasOwnProperty('itemsExpression')) { setFieldValue('openForms.itemsExpression', undefined); } - if (!values.hasOwnProperty(name)) { + if (!isNestedKeySet(values, name)) { setFieldValue(name, [{value: '', label: '', openForms: {translations: {}}}]); } break; } case 'variable': { - if (values.hasOwnProperty(name)) { + if (isNestedKeySet(values, name)) { setFieldValue(name, undefined); } break; diff --git a/src/components/formio/select.tsx b/src/components/formio/select.tsx index 6c9e4dbe..54f8b5a5 100644 --- a/src/components/formio/select.tsx +++ b/src/components/formio/select.tsx @@ -8,6 +8,7 @@ import type { } from 'react-select/dist/declarations/src'; import Component from './component'; +import Description from './description'; // See https://react-select.com/typescript @@ -20,6 +21,7 @@ export interface SelectProps< label?: React.ReactNode; required?: boolean; tooltip?: string; + description?: string; isClearable?: boolean; valueProperty?: string; onChange?: (event: {target: {name: string; value: any}}) => void; @@ -75,6 +77,7 @@ function Select< label, required = false, tooltip = '', + description = '', isClearable = false, valueProperty = 'value', onChange, @@ -124,6 +127,7 @@ function Select< value={value} />
+ {description && } ); } diff --git a/src/registry/index.tsx b/src/registry/index.tsx index 23357d61..8c7dddec 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -13,6 +13,7 @@ import NumberField from './number'; import PhoneNumber from './phonenumber'; import Postcode from './postcode'; import Radio from './radio'; +import Select from './select'; import Selectboxes from './selectboxes'; import Textarea from './textarea'; import TextField from './textfield'; @@ -45,6 +46,7 @@ const REGISTRY: Registry = { postcode: Postcode, file: FileUpload, selectboxes: Selectboxes, + select: Select, currency: Currency, radio: Radio, // Special types: diff --git a/src/registry/select/edit-validation.ts b/src/registry/select/edit-validation.ts new file mode 100644 index 00000000..bf4773ef --- /dev/null +++ b/src/registry/select/edit-validation.ts @@ -0,0 +1,27 @@ +import {IntlShape} from 'react-intl'; +import {z} from 'zod'; + +import {buildCommonSchema, jsonSchema, optionSchema} from '@/registry/validation'; + +// z.object(...).or(z.object(...)) based on openForms.dataSrc doesn't seem to work, +// looks like the union validation only works if the discriminator is in the top level +// object :( +// so we mark each aspect as optional so that *when* it is provided, we can run the +// validation +const buildValuesSchema = (intl: IntlShape) => + z.object({ + data: z.object({ + // *can* be empty if an itemsExpression is set, it's only added back at runtime in + // the backend + values: optionSchema(intl).array().min(1).optional(), + }), + openForms: z.object({ + dataSrc: z.union([z.literal('manual'), z.literal('variable')]), + // TODO: wire up infernologic type checking + itemsExpression: jsonSchema.optional(), + }), + }); + +const schema = (intl: IntlShape) => buildCommonSchema(intl).and(buildValuesSchema(intl)); + +export default schema; diff --git a/src/registry/select/edit.tsx b/src/registry/select/edit.tsx new file mode 100644 index 00000000..42f3cb52 --- /dev/null +++ b/src/registry/select/edit.tsx @@ -0,0 +1,208 @@ +import {SelectComponentSchema} from '@open-formulieren/types'; +import {Option} from '@open-formulieren/types/lib/formio/common'; +import {useFormikContext} from 'formik'; +import isEqual from 'lodash.isequal'; +import {useLayoutEffect} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import { + BuilderTabs, + ClearOnHide, + Description, + Hidden, + IsSensitiveData, + Key, + Label, + Multiple, + PresentationConfig, + Registration, + SimpleConditional, + Tooltip, + Translations, + Validate, + ValuesConfig, + ValuesTranslations, + useDeriveComponentKey, +} from '@/components/builder'; +import {LABELS} from '@/components/builder/messages'; +import {Select, TabList, TabPanel, Tabs} from '@/components/formio'; +import {getErrorNames} from '@/utils/errors'; + +import {EditFormDefinition} from '../types'; +import {checkIsManualOptions} from './helpers'; + +/** + * Form to configure a Formio 'select' type component. + */ +const EditForm: EditFormDefinition = () => { + const intl = useIntl(); + const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); + const {values, errors, setFieldValue} = useFormikContext(); + const { + openForms: {dataSrc}, + defaultValue, + multiple, + } = values; + + const erroredFields = Object.keys(errors).length + ? getErrorNames(errors) + : []; + // TODO: pattern match instead of just string inclusion? + // TODO: move into more generically usuable utility when we implement other component + // types + const hasAnyError = (...fieldNames: string[]): boolean => { + if (!erroredFields.length) return false; + return fieldNames.some(name => erroredFields.includes(name)); + }; + + Validate.useManageValidatorsTranslations(['required']); + + const isManualOptions = checkIsManualOptions(values); + const options = isManualOptions ? values.data?.values || [] : []; + + // Ensure that form state is reset if the values source changes. + useLayoutEffect(() => { + const emptyDefaultValue = multiple ? [] : ''; + if (dataSrc !== 'variable' || isEqual(defaultValue, emptyDefaultValue)) return; + setFieldValue('defaultValue', emptyDefaultValue); + }, [dataSrc]); + + return ( + + + + + + + + + + {/* Basic tab */} + + + + {/* Advanced tab */} + + + + + {/* Validation tab */} + + + + + + + {/* Registration tab */} + + + + + {/* Translations */} + + + propertyLabels={{ + label: intl.formatMessage(LABELS.label), + description: intl.formatMessage(LABELS.description), + tooltip: intl.formatMessage(LABELS.tooltip), + }} + > + name="data.values" /> + + + + ); +}; + +EditForm.defaultValues = { + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + openForms: { + dataSrc: 'manual', + translations: {}, + }, + // fixed, this is what itemsExpression results in via the backend. Do not confuse with + // openForms.dataSrc! + dataSrc: 'values', + data: {values: [{value: '', label: ''}]}, + // TODO: at some point we can allow an itemsExpression for this too + defaultValue: '', + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + translatedErrors: {}, + registration: { + attribute: '', + }, +}; + +interface DefaultValueProps { + options: Option[]; + multiple: boolean; +} + +const DefaultValue: React.FC = ({options, multiple}) => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'defaultValue' builder field", + defaultMessage: 'This will be the initial value for this field before user interaction.', + }); + + return ( + + ); +}; + +export default Preview; diff --git a/src/registry/select/select-validation.stories.ts b/src/registry/select/select-validation.stories.ts new file mode 100644 index 00000000..529c09fc --- /dev/null +++ b/src/registry/select/select-validation.stories.ts @@ -0,0 +1,60 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {userEvent, within} from '@storybook/testing-library'; + +import ComponentEditForm from '@/components/ComponentEditForm'; +import {BuilderContextDecorator} from '@/sb-decorators'; + +export default { + title: 'Builder components/Select/Validations', + component: ComponentEditForm, + decorators: [BuilderContextDecorator], + parameters: { + builder: {enableContext: true}, + }, + args: { + isNew: true, + component: { + id: 'wqimsadk', + type: 'select', + key: 'select', + label: 'A select field', + openForms: { + dataSrc: 'manual', + translations: {}, + }, + values: [{value: '', label: ''}], + defaultValue: '', + }, + + builderInfo: { + title: 'Select', + icon: 'th-list', + group: 'basic', + weight: 70, + schema: {}, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const ManualMinimumOneValue: Story = { + name: 'Manual values: must have at least one non-empty value', + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Option values and labels are required fields', async () => { + // a value must be set, otherwise there's nothing to check and a label must be + // set, otherwise there is no clickable/accessible label for an option. + + // we trigger input validation by touching the field and clearing it again + const labelInput = canvas.getByLabelText('Option label'); + await userEvent.type(labelInput, 'Foo'); + await userEvent.clear(labelInput); + await userEvent.keyboard('[Tab]'); + await expect(await canvas.findByText('The option label is a required field.')).toBeVisible(); + await expect(await canvas.findByText('The option value is a required field.')).toBeVisible(); + }); + }, +};