From 0e36788fcd56edd5a9f13be30de98b6d4d6c7ce9 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 14:44:30 +0200 Subject: [PATCH 01/19] :pencil: Update roadmap in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bae1ec60..4a01a5a8 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,10 @@ The builder form is the form + preview shown in the edit component modal. - [ ] `selectboxes` - [ ] `select` - [ ] `radio` - - [ ] `number` + - [x] `number` - [ ] `currency` - [x] `email` - - [ ] `date` + - [x] `date` - [ ] `datetime` - [ ] `time` - [ ] `phoneNumber` From 1970b7a77d3c67aef3975b7b88538276d0b55fad Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 14:45:36 +0200 Subject: [PATCH 02/19] :arrow_up: Upgrade to types v0.11.0 --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f4f07b3..1f8da627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@formatjs/cli": "^6.1.1", "@formatjs/ts-transformer": "^3.12.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@open-formulieren/types": "^0.10.0", + "@open-formulieren/types": "^0.11.0", "@storybook/addon-actions": "^7.3.2", "@storybook/addon-essentials": "^7.3.2", "@storybook/addon-interactions": "^7.3.2", @@ -5868,9 +5868,9 @@ } }, "node_modules/@open-formulieren/types": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.10.0.tgz", - "integrity": "sha512-OIrMhT1UHvQk2rYFmzts4LUhq8t8JqkOitjRcA52I6Nfgf5VymFZfZDEcIFzns7mjFjeGKaxdlviHy+KEj91GA==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.11.0.tgz", + "integrity": "sha512-yQhxIfLTCTlb9+nIc/jzoa8tVpX2auT1TiU1QDc62A/tWQSqt4V9rBvgTQGhFedROYr3TggL4bGVV0kimv4tVg==", "dev": true }, "node_modules/@pkgjs/parseargs": { @@ -35272,9 +35272,9 @@ } }, "@open-formulieren/types": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.10.0.tgz", - "integrity": "sha512-OIrMhT1UHvQk2rYFmzts4LUhq8t8JqkOitjRcA52I6Nfgf5VymFZfZDEcIFzns7mjFjeGKaxdlviHy+KEj91GA==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@open-formulieren/types/-/types-0.11.0.tgz", + "integrity": "sha512-yQhxIfLTCTlb9+nIc/jzoa8tVpX2auT1TiU1QDc62A/tWQSqt4V9rBvgTQGhFedROYr3TggL4bGVV0kimv4tVg==", "dev": true }, "@pkgjs/parseargs": { diff --git a/package.json b/package.json index 25ad1653..8d2922ab 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@formatjs/cli": "^6.1.1", "@formatjs/ts-transformer": "^3.12.0", "@fortawesome/fontawesome-free": "^6.4.0", - "@open-formulieren/types": "^0.10.0", + "@open-formulieren/types": "^0.11.0", "@storybook/addon-actions": "^7.3.2", "@storybook/addon-essentials": "^7.3.2", "@storybook/addon-interactions": "^7.3.2", From cd2f1e43a65d57ea89198659ef1c0091e24fd3b9 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 15:04:01 +0200 Subject: [PATCH 03/19] :white_check_mark: Add some missing interaction tests --- src/components/ComponentPreview.stories.tsx | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 57cd164e..987e0df2 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -236,3 +236,72 @@ export const NumberField: Story = { await expect(input).toHaveDisplayValue('-3.14'); }, }; + +export const DateField: Story = { + render: Template, + + args: { + component: { + type: 'date', + id: 'date', + key: 'datePreview', + label: 'Date preview', + description: 'A preview of the date Formio component', + hidden: true, // must be ignored + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Date preview'); + await canvas.findByText('A preview of the date Formio component'); + + // check that the input name is set correctly + const input = canvas.getByLabelText('Date preview'); + // @ts-ignore + await expect(input.getAttribute('name')).toBe(args.component.key); + + // typing into native date inputs is not reliable, so no such checks here + }, +}; + +export const DateFieldMultiple: Story = { + render: Template, + + args: { + component: { + type: 'date', + id: 'date', + key: 'datePreview', + label: 'Date preview', + description: 'Description only once', + hidden: true, // must be ignored + multiple: true, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-datePreview[0]'); + await expect(input1).toHaveDisplayValue(''); + await expect(input1.type).toEqual('date'); + + // the description should be rendered only once, even with > 1 inputs + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input2 = canvas.getByTestId('input-datePreview[1]'); + await expect(input2).toHaveDisplayValue(''); + await expect(canvas.queryAllByText('Description only once')).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons.length).toBe(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-datePreview[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-datePreview[1]')).not.toBeInTheDocument(); + }, +}; From 13acb52d84f7082135b6b268873ba20acb5ba1de Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 15:10:02 +0200 Subject: [PATCH 04/19] :white_check_mark: Add stories for datetime component preview --- src/components/ComponentPreview.stories.tsx | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 987e0df2..9e00700b 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -305,3 +305,72 @@ export const DateFieldMultiple: Story = { await expect(canvas.queryByTestId('input-datePreview[1]')).not.toBeInTheDocument(); }, }; + +export const DateTimeField: Story = { + render: Template, + + args: { + component: { + type: 'datetime', + id: 'datetime', + key: 'datetimePreview', + label: 'DateTime preview', + description: 'A preview of the datetime Formio component', + hidden: true, // must be ignored + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('DateTime preview'); + await canvas.findByText('A preview of the datetime Formio component'); + + // check that the input name is set correctly + const input = canvas.getByLabelText('DateTime preview'); + // @ts-ignore + await expect(input.getAttribute('name')).toBe(args.component.key); + + // typing into native datetime inputs is not reliable, so no such checks here + }, +}; + +export const DateTimeFieldMultiple: Story = { + render: Template, + + args: { + component: { + type: 'datetime', + id: 'datetime', + key: 'datetimePreview', + label: 'DateTime preview', + description: 'Description only once', + hidden: true, // must be ignored + multiple: true, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-datetimePreview[0]'); + await expect(input1).toHaveDisplayValue(''); + await expect(input1.type).toEqual('datetime-local'); + + // the description should be rendered only once, even with > 1 inputs + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input2 = canvas.getByTestId('input-datetimePreview[1]'); + await expect(input2).toHaveDisplayValue(''); + await expect(canvas.queryAllByText('Description only once')).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons.length).toBe(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-datetimePreview[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-datetimePreview[1]')).not.toBeInTheDocument(); + }, +}; From 9f41201fe628178b5d81c5d6811aa98e5196a6dd Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 15:27:52 +0200 Subject: [PATCH 05/19] :sparkles: Implement a minimal datetime formio component --- .../formio/datetimefield.stories.ts | 101 ++++++++++++++++++ src/components/formio/datetimefield.tsx | 81 ++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/components/formio/datetimefield.stories.ts create mode 100644 src/components/formio/datetimefield.tsx diff --git a/src/components/formio/datetimefield.stories.ts b/src/components/formio/datetimefield.stories.ts new file mode 100644 index 00000000..7e890ab8 --- /dev/null +++ b/src/components/formio/datetimefield.stories.ts @@ -0,0 +1,101 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {userEvent, within} from '@storybook/testing-library'; + +import {withFormik} from '@/sb-decorators'; + +import DateTimeField from './datetimefield'; + +export default { + title: 'Formio/Components/DateTimeField', + component: DateTimeField, + decorators: [withFormik], + parameters: { + modal: {noModal: true}, + formik: {initialValues: {'my-datetimefield': '1980-01-01T12:00'}}, + }, + args: { + name: 'my-datetimefield', + }, +} as Meta; + +type Story = StoryObj; + +export const Required: Story = { + args: { + required: true, + label: 'A required datetimefield', + }, +}; + +export const WithoutLabel: Story = { + args: { + label: '', + }, +}; + +export const WithToolTip: Story = { + args: { + label: 'With tooltip', + tooltip: 'Hiya!', + required: false, + }, +}; + +export const Multiple: Story = { + args: { + label: 'Multiple inputs', + description: 'Array of dates instead of a single date value', + multiple: true, + }, + + parameters: { + formik: { + initialValues: {'my-datetimefield': ['1980-01-01T12:00']}, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-my-datetimefield[0]'); + await expect(input1).toHaveDisplayValue('1980-01-01T12:00'); + + await userEvent.clear(input1); + await expect(input1).toHaveDisplayValue(''); + + // the label & description should be rendered only once, even with > 1 inputs + await expect(canvas.queryAllByText('Multiple inputs')).toHaveLength(1); + await expect( + canvas.queryAllByText('Array of dates instead of a single date value') + ).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons).toHaveLength(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-my-datetimefield[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-my-datetimefield[1]')).not.toBeInTheDocument(); + }, +}; + +export const WithErrors: Story = { + args: { + label: 'With errors', + }, + + parameters: { + formik: { + initialValues: {'my-datetimefield': ''}, + initialErrors: {'my-datetimefield': 'Example error', 'other-field': 'Other error'}, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText('Other error')).not.toBeInTheDocument(); + await expect(canvas.queryByText('Example error')).toBeInTheDocument(); + }, +}; diff --git a/src/components/formio/datetimefield.tsx b/src/components/formio/datetimefield.tsx new file mode 100644 index 00000000..1a602e07 --- /dev/null +++ b/src/components/formio/datetimefield.tsx @@ -0,0 +1,81 @@ +import clsx from 'clsx'; +import {Field, useFormikContext} from 'formik'; +import {useContext} from 'react'; + +import {RenderContext} from '@/context'; +import {ErrorList, useValidationErrors} from '@/utils/errors'; + +import Component from './component'; +import Description from './description'; +import {withMultiple} from './multiple'; + +export interface DateTimeFieldProps { + name: string; + label?: React.ReactNode; + required?: boolean; + tooltip?: string; + description?: string; +} + +// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local +export const DateTimeField: React.FC = ({ + name, + label, + required = false, + tooltip = '', + description = '', + ...props +}) => { + const {getFieldProps} = useFormikContext(); + const {bareInput} = useContext(RenderContext); + const {errors, hasErrors} = useValidationErrors(name); + + const htmlId = `editform-${name}`; + + const {value} = getFieldProps(name); + + // let's not bother with date pickers - use the native browser date input instead. + const inputField = ( + + ); + + // 'bare input' is actually a little bit more than just the input, looking at the + // vanillay formio implementation. + if (bareInput) { + return ( + <> + {inputField} + + + ); + } + + // default-mode, wrapping the field with label, description etc. + return ( + +
{inputField}
+ {description && } +
+ ); +}; + +export const DateTimeFieldMultiple = withMultiple(DateTimeField, ''); +export default DateTimeFieldMultiple; From 30089c461047953929de317e60cb76e15c4babad Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 16:01:02 +0200 Subject: [PATCH 06/19] :sparkles: Implement datetime component type --- .../ComponentConfiguration.stories.tsx | 77 +++++++ src/components/formio/index.ts | 1 + .../datetime/datetime-component.stories.ts | 82 +++++++ src/registry/datetime/edit-validation.ts | 53 +++++ src/registry/datetime/edit.tsx | 205 ++++++++++++++++++ src/registry/datetime/index.ts | 10 + src/registry/datetime/preview.tsx | 40 ++++ .../validation-datetimeconstraint.stories.ts | 183 ++++++++++++++++ .../datetime/validation/constraint-mode.tsx | 115 ++++++++++ .../validation/fixed-value-datetimefield.tsx | 50 +++++ src/registry/datetime/validation/index.tsx | 44 ++++ .../datetime/validation/relative-delta.tsx | 184 ++++++++++++++++ src/registry/datetime/validation/types.ts | 13 ++ src/registry/index.tsx | 2 + 14 files changed, 1059 insertions(+) create mode 100644 src/registry/datetime/datetime-component.stories.ts create mode 100644 src/registry/datetime/edit-validation.ts create mode 100644 src/registry/datetime/edit.tsx create mode 100644 src/registry/datetime/index.ts create mode 100644 src/registry/datetime/preview.tsx create mode 100644 src/registry/datetime/validation-datetimeconstraint.stories.ts create mode 100644 src/registry/datetime/validation/constraint-mode.tsx create mode 100644 src/registry/datetime/validation/fixed-value-datetimefield.tsx create mode 100644 src/registry/datetime/validation/index.tsx create mode 100644 src/registry/datetime/validation/relative-delta.tsx create mode 100644 src/registry/datetime/validation/types.ts diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 712b4189..f4810a59 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -421,3 +421,80 @@ export const DateField: Story = { // datepicker helps in enforcing only valid dates. }, }; + +export const DateTimeField: Story = { + render: Template, + name: 'type: datetime', + + args: { + component: { + id: 'wekruya', + type: 'datetime', + key: 'datetime', + label: 'A datetime field', + validate: { + required: false, + }, + }, + + builderInfo: { + title: 'Date/Time Field', + icon: 'calendar-plus', + group: 'basic', + weight: 10, + schema: {}, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A datetime field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aDatetimeField'); + }); + await expect(canvas.getByLabelText('Description')).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(); + await expect(canvas.queryByLabelText('Placeholder')).not.toBeInTheDocument(); + + // ensure that changing fields in the edit form properly update the preview + const preview = within(canvas.getByTestId('componentPreview')); + + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label'); + expect(await preview.findByText('Updated preview label')); + + const previewInput = preview.getByLabelText('Updated preview label'); + await expect(previewInput).toHaveDisplayValue(''); + await expect(previewInput.type).toEqual('datetime-local'); + + // 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.change(keyInput, {target: {value: 'customKey'}}); + await userEvent.click(canvas.getByRole('tab', {name: 'Advanced'})); + 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'); + + // check that toggling the 'multiple' checkbox properly updates the preview and default + // value field + await userEvent.click(canvas.getByLabelText('Multiple values')); + await userEvent.click(preview.getByRole('button', {name: 'Add another'})); + // await expect(preview.getByTestId('input-customKey[0]')).toHaveDisplayValue(''); + // test for the default value inputs -> these don't have accessible labels/names :( + const addButtons = canvas.getAllByRole('button', {name: 'Add another'}); + await userEvent.click(addButtons[0]); + await waitFor(async () => { + await expect(await canvas.findByTestId('input-defaultValue[0]')).toBeVisible(); + }); + + // check that default value is e-mail validated + const defaultInput0 = canvas.getByTestId('input-defaultValue[0]'); + await expect(defaultInput0.type).toEqual('datetime-local'); + // userEvent.type does not reliably work with datetime-local input + }, +}; diff --git a/src/components/formio/index.ts b/src/components/formio/index.ts index 3cc93ba1..9d7580d2 100644 --- a/src/components/formio/index.ts +++ b/src/components/formio/index.ts @@ -16,6 +16,7 @@ export {default as Tooltip} from './tooltip'; export {default as TextField} from './textfield'; export {default as Checkbox} from './checkbox'; export {default as DateField} from './datefield'; +export {default as DateTimeField} from './datetimefield'; export {default as Panel} from './panel'; export {default as Select} from './select'; export {default as NumberField} from './number'; diff --git a/src/registry/datetime/datetime-component.stories.ts b/src/registry/datetime/datetime-component.stories.ts new file mode 100644 index 00000000..fdda3b81 --- /dev/null +++ b/src/registry/datetime/datetime-component.stories.ts @@ -0,0 +1,82 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {userEvent, within} from '@storybook/testing-library'; + +import ComponentEditForm from '@/components/ComponentEditForm'; +import {withFormik} from '@/sb-decorators'; + +export default { + title: 'Builder components/DateTimeField', + component: ComponentEditForm, + decorators: [withFormik], + parameters: {}, + args: { + isNew: true, + component: { + id: 'wekruya', + type: 'datetime', + key: 'datetime', + label: 'A datetime field', + validate: { + required: false, + }, + }, + + builderInfo: { + title: 'Date/Time Field', + icon: 'calendar-plus', + group: 'basic', + weight: 10, + schema: {}, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const ValidateDeltaConstraintConfiguration: Story = { + name: 'Validate datetime constraint configuration: delta', + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Navigate to validation tab and open maxDate configuration', async () => { + await userEvent.click(canvas.getByRole('link', {name: 'Validation'})); + await userEvent.click(canvas.getByText(/Maximum date/)); + expect(await canvas.findByText('Mode preset')).toBeVisible(); + }); + + await step('Configure relative to variable', async () => { + canvas.getByLabelText('Mode preset').focus(); + await userEvent.keyboard('[ArrowDown]'); + await userEvent.click(await canvas.findByText('Relative to variable')); + + // set up an invalid variable name + const variableInput = await canvas.findByLabelText('Variable'); + expect(variableInput).toBeVisible(); + expect(variableInput).toHaveDisplayValue('now'); + await userEvent.clear(variableInput); + await userEvent.type(variableInput, 'invalid because spaces'); + + // enter invalid values for the delta + const yearInput = await canvas.findByLabelText('Years'); + expect(yearInput).toHaveDisplayValue(''); + await userEvent.type(yearInput, '3.14'); + + const monthInput = await canvas.findByLabelText('Months'); + expect(monthInput).toHaveDisplayValue(''); + await userEvent.type(monthInput, '-3'); + + const dayInput = await canvas.findByLabelText('Days'); + expect(dayInput).toHaveDisplayValue(''); + await userEvent.type(dayInput, '0'); + await userEvent.keyboard('[Tab]'); + expect(dayInput).not.toHaveFocus(); + }); + + await step('Check the validation errors', async () => { + expect(await canvas.findByText(/The property name must only contain/)).toBeVisible(); + expect(await canvas.findByText('Expected integer, received float')).toBeVisible(); + expect(await canvas.findByText('Number must be greater than or equal to 0')).toBeVisible(); + }); + }, +}; diff --git a/src/registry/datetime/edit-validation.ts b/src/registry/datetime/edit-validation.ts new file mode 100644 index 00000000..8c442a72 --- /dev/null +++ b/src/registry/datetime/edit-validation.ts @@ -0,0 +1,53 @@ +import {IntlShape} from 'react-intl'; +import {z} from 'zod'; + +import {buildCommonSchema, buildKeySchema} from '@/registry/validation'; + +const dateSchema = z.coerce.date().optional(); + +// case for when component.multiple=false +const singleValueSchema = z + .object({multiple: z.literal(false)}) + .and(z.object({defaultValue: dateSchema})); + +// case for when component.multiple=true +const multipleValueSchema = z + .object({multiple: z.literal(true)}) + .and(z.object({defaultValue: dateSchema.array()})); + +const defaultValueSchema = singleValueSchema.or(multipleValueSchema); + +const noMode = z.object({mode: z.literal('')}); +const future = z.object({ + mode: z.literal('future'), +}); +const past = z.object({ + mode: z.literal('past'), +}); + +const buildRelativeToVariable = (intl: IntlShape) => + z.object({ + mode: z.literal('relativeToVariable'), + operator: z.literal('add').or(z.literal('subtract')), + variable: buildKeySchema(intl), + delta: z.object({ + years: z.null().or(z.number().int().gte(0)).optional(), + months: z.null().or(z.number().int().gte(0)).optional(), + days: z.null().or(z.number().int().gte(0)).optional(), + }), + }); + +const buildDateSpecific = (intl: IntlShape) => + z.object({ + openForms: z + .object({ + minDate: z.union([noMode, future, buildRelativeToVariable(intl)]), + maxDate: z.union([noMode, past, buildRelativeToVariable(intl)]), + }) + .optional(), + }); + +const schema = (intl: IntlShape) => + buildCommonSchema(intl).and(defaultValueSchema).and(buildDateSpecific(intl)); + +export default schema; diff --git a/src/registry/datetime/edit.tsx b/src/registry/datetime/edit.tsx new file mode 100644 index 00000000..60e41899 --- /dev/null +++ b/src/registry/datetime/edit.tsx @@ -0,0 +1,205 @@ +import {DateTimeComponentSchema} from '@open-formulieren/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import { + BuilderTabs, + ClearOnHide, + Description, + Hidden, + IsSensitiveData, + Key, + Label, + Multiple, + Prefill, + PresentationConfig, + ReadOnly, + Registration, + SimpleConditional, + Tooltip, + Translations, + Validate, + useDeriveComponentKey, +} from '@/components/builder'; +import {LABELS} from '@/components/builder/messages'; +import {DateTimeField, TabList, TabPanel, Tabs} from '@/components/formio'; +import {EditFormDefinition} from '@/registry/types'; +import {getErrorNames} from '@/utils/errors'; + +import DateTimeConstraintValidation from './validation'; + +/** + * Form to configure a Formio 'date' type component. + */ +const EditForm: EditFormDefinition = () => { + const intl = useIntl(); + const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); + const { + values: {multiple = false}, + errors, + } = useFormikContext(); + + const erroredFields = Object.keys(errors).length ? getErrorNames(errors) : []; + // TODO: pattern match instead of just string inclusion? + // TODO: move into more generically usable 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']); + + return ( + + + + + + + + + + + {/* Basic tab */} + + + {/* Advanced tab */} + + + + {/* Validation tab */} + + + + + + + + {/* Registration tab */} + + + + {/* Prefill tab */} + + + + {/* Translations */} + + + propertyLabels={{ + label: intl.formatMessage(LABELS.label), + description: intl.formatMessage(LABELS.description), + tooltip: intl.formatMessage(LABELS.tooltip), + }} + /> + + + ); +}; + +EditForm.defaultValues = { + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + defaultValue: '', + disabled: false, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + translatedErrors: {}, + // Defaults from https://github.com/formio/formio.js/blob/ + // bebc2ad73cad138a6de0a8247df47f0085a314cc/src/components/datetime/DateTime.js#L22 + datePicker: { + showWeeks: true, + startingDay: 0, + initDate: '', + minMode: 'day', + maxMode: 'year', + yearRows: 4, + yearColumns: 5, + minDate: null, + maxDate: null, + }, + openForms: { + translations: {}, + minDate: {mode: ''}, + maxDate: {mode: ''}, + }, + // Registration tab + registration: { + attribute: '', + }, + // Prefill tab + prefill: { + plugin: null, + attribute: null, + identifierRole: 'main', + }, +}; + +interface DefaultValueProps { + multiple: boolean; +} + +const DefaultValue: React.FC = ({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 ( + } + tooltip={tooltip} + multiple={multiple} + /> + ); +}; + +export default EditForm; diff --git a/src/registry/datetime/index.ts b/src/registry/datetime/index.ts new file mode 100644 index 00000000..f636dcf3 --- /dev/null +++ b/src/registry/datetime/index.ts @@ -0,0 +1,10 @@ +import EditForm from './edit'; +import validationSchema from './edit-validation'; +import Preview from './preview'; + +export default { + edit: EditForm, + editSchema: validationSchema, + preview: Preview, + defaultValue: '', +}; diff --git a/src/registry/datetime/preview.tsx b/src/registry/datetime/preview.tsx new file mode 100644 index 00000000..05e3dd4e --- /dev/null +++ b/src/registry/datetime/preview.tsx @@ -0,0 +1,40 @@ +import {DateTimeComponentSchema} from '@open-formulieren/types'; + +import {DateTimeField} from '@/components/formio'; + +import {ComponentPreviewProps} from '../types'; + +/** + * Show a formio date component preview. + * + * NOTE: for the time being, this is rendered in the default Formio bootstrap style, + * however at some point this should use the components of + * @open-formulieren/formio-renderer instead for a more accurate preview. + */ +const Preview: React.FC> = ({component}) => { + const { + key, + label, + description, + placeholder, + tooltip, + validate = {}, + disabled = false, + multiple = false, + } = component; + const {required = false} = validate; + return ( + + ); +}; + +export default Preview; diff --git a/src/registry/datetime/validation-datetimeconstraint.stories.ts b/src/registry/datetime/validation-datetimeconstraint.stories.ts new file mode 100644 index 00000000..a91beeed --- /dev/null +++ b/src/registry/datetime/validation-datetimeconstraint.stories.ts @@ -0,0 +1,183 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {userEvent, within} from '@storybook/testing-library'; + +import {withFormik} from '@/sb-decorators'; + +import DateTimeConstraintValidation from './validation'; + +export default { + title: 'Builder components/DateTimeField/DateTimeConstraintValidation', + component: DateTimeConstraintValidation, + decorators: [withFormik], + parameters: { + modal: {noModal: true}, + formik: { + initialValues: { + openForms: { + minDate: {mode: ''}, + maxDate: {mode: ''}, + }, + datePicker: { + showWeeks: true, + startingDay: 0, + initDate: '', + minMode: 'day', + maxMode: 'year', + yearRows: 4, + yearColumns: 5, + minDate: null, + maxDate: null, + }, + }, + }, + }, + args: { + constraint: 'minDate', + }, + argTypes: { + constraint: { + options: ['minDate', 'maxDate'], + control: {type: 'inline-radio'}, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const InitialState: Story = {}; + +export const FixedValue: Story = { + parameters: { + formik: { + initialValues: { + openForms: { + minDate: { + mode: 'fixedValue', + }, + maxDate: {mode: ''}, + }, + datePicker: { + showWeeks: true, + startingDay: 0, + initDate: '', + minMode: 'day', + maxMode: 'year', + yearRows: 4, + yearColumns: 5, + minDate: '2023-01-01', + maxDate: null, + }, + }, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // Expand the panel + await userEvent.click(canvas.getByText(/date/)); + + expect(await canvas.findByText('Fixed value')).toBeVisible(); + const datefield = await canvas.findByLabelText('Minimum date'); + expect(datefield).toBeVisible(); + expect(datefield).toHaveDisplayValue('2023-01-01'); + }, +}; + +export const FutureOrPast: Story = { + parameters: { + formik: { + initialValues: { + openForms: { + minDate: {mode: 'future'}, + maxDate: {mode: ''}, + }, + datePicker: { + showWeeks: true, + startingDay: 0, + initDate: '', + minMode: 'day', + maxMode: 'year', + yearRows: 4, + yearColumns: 5, + minDate: '2023-01-01', + maxDate: null, + }, + }, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // Expand the panel + await userEvent.click(canvas.getByText(/date/)); + + expect(await canvas.findByText('In the future')).toBeVisible(); + const checkbox = canvas.queryByLabelText('Including today'); + expect(checkbox).not.toBeInTheDocument(); + }, +}; + +export const RelativeToVariable: Story = { + parameters: { + formik: { + initialValues: { + openForms: { + minDate: { + mode: 'relativeToVariable', + variable: 'now', + delta: { + years: null, + months: null, + days: null, + }, + operator: 'add', + }, + maxDate: {mode: ''}, + }, + }, + }, + }, + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + // Expand the panel + await userEvent.click(canvas.getByText(/date/)); + + expect(await canvas.findByText('Relative to variable')).toBeVisible(); + + await step('Configuring the operator', async () => { + const operator = await canvas.findByLabelText('Add/subtract duration'); + expect(operator).toBeVisible(); + expect(await canvas.findByText('Add')).toBeVisible(); + }); + + await step('Configuring the variable', async () => { + const variableInput = await canvas.findByLabelText('Variable'); + expect(variableInput).toBeVisible(); + userEvent.clear(variableInput); + await userEvent.type(variableInput, 'someOtherKey'); + expect(variableInput).toHaveDisplayValue('someOtherKey'); + }); + + await step('Configuring the delta', async () => { + const yearInput = await canvas.findByLabelText('Years'); + expect(yearInput).toBeVisible(); + await userEvent.type(yearInput, '3'); + expect(yearInput).toHaveValue(3); + + const monthInput = await canvas.findByLabelText('Months'); + expect(monthInput).toBeVisible(); + await userEvent.type(monthInput, '1'); + expect(monthInput).toHaveValue(1); + + const dayInput = await canvas.findByLabelText('Days'); + expect(dayInput).toBeVisible(); + await userEvent.type(dayInput, '0'); + expect(dayInput).toHaveValue(0); + }); + }, +}; diff --git a/src/registry/datetime/validation/constraint-mode.tsx b/src/registry/datetime/validation/constraint-mode.tsx new file mode 100644 index 00000000..06af8e36 --- /dev/null +++ b/src/registry/datetime/validation/constraint-mode.tsx @@ -0,0 +1,115 @@ +import {DateTimeComponentSchema} from '@open-formulieren/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage, MessageDescriptor, defineMessage, useIntl} from 'react-intl'; + +import {Select} from '@/components/formio'; + +import {AllModes, AllPossibleConstraints, DateConstraintKey, NonEmptyModes} from './types'; + +// Mappings of value-label to produce dropdown options. Note that you need to filter out +// only the options relevant to the particular field. +const ALL_DATE_CONSTRAINT_MODE_OPTIONS: Array<{ + label: MessageDescriptor; + value: NonEmptyModes; +}> = [ + { + value: 'fixedValue', + label: defineMessage({ + description: "Date constraint mode 'fixedValue' label", + defaultMessage: 'Fixed value', + }), + }, + { + value: 'future', + label: defineMessage({ + description: "Date constraint mode 'future' label", + defaultMessage: 'In the future', + }), + }, + { + value: 'past', + label: defineMessage({ + description: "Date constraint mode 'past' label", + defaultMessage: 'In the past', + }), + }, + { + value: 'relativeToVariable', + label: defineMessage({ + description: "Date constraint mode 'relativeToVariable' label", + defaultMessage: 'Relative to variable', + }), + }, +]; + +const MODES_TO_EXCLUDE: Record = { + minDate: ['past'], + maxDate: ['future'], +}; + +const DEFAULT_VALUES: { + [K in AllPossibleConstraints['mode']]: Omit, 'mode'>; +} = { + '': {}, + fixedValue: {}, + future: {}, + past: {}, + relativeToVariable: { + variable: 'now', + delta: { + years: null, + months: null, + days: null, + }, + operator: 'add', + }, +}; + +export interface ModeSelectProps { + constraint: DateConstraintKey; +} + +const ModeSelect: React.FC = ({constraint}) => { + const fieldName = `openForms.${constraint}.mode`; + const intl = useIntl(); + const {setFieldValue} = useFormikContext(); + + // filter out the validation modes not relevant for this particular constraint + const modesToExclude = MODES_TO_EXCLUDE[constraint]; + const options = ALL_DATE_CONSTRAINT_MODE_OPTIONS.filter( + opt => !modesToExclude.includes(opt.value) + ); + + return ( + + } + tooltip={tooltip} + options={[ + { + value: 'add', + label: intl.formatMessage({ + description: "Operator 'add' option label", + defaultMessage: 'Add', + }), + }, + { + value: 'subtract', + label: intl.formatMessage({ + description: "Operator 'subtract' option label", + defaultMessage: 'Subtract', + }), + }, + ]} + /> + ); +}; + +export interface VariableProps { + name: `openForms.${DateConstraintKey}.variable`; +} + +// XXX: at some point we should provide all available variables in the context so that +// you can select the variable from a dropdown rather than having to type the key +// yourself. +const Variable: React.FC = ({name}) => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'variable' in relative delta date constraint validation", + defaultMessage: 'Provide the key of a static, component, or user defined variable.', + }); + return ( + + } + tooltip={tooltip} + spellCheck={false} + /> + ); +}; + +export interface YearsProps { + name: `openForms.${DateConstraintKey}.delta.years`; +} + +const Years: React.FC = ({name}) => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'delta.years' in relative delta date constraint validation", + defaultMessage: 'Number of years. Empty values are ignored.', + }); + return ( + + } + tooltip={tooltip} + step={1} + min={0} + /> + ); +}; + +export interface MonthsProps { + name: `openForms.${DateConstraintKey}.delta.months`; +} + +const Months: React.FC = ({name}) => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'delta.months' in relative delta date constraint validation", + defaultMessage: 'Number of months. Empty values are ignored.', + }); + return ( + + } + tooltip={tooltip} + step={1} + min={0} + /> + ); +}; + +export interface DaysProps { + name: `openForms.${DateConstraintKey}.delta.days`; +} + +const Days: React.FC = ({name}) => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'delta.days' in relative delta date constraint validation", + defaultMessage: 'Number of days. Empty values are ignored.', + }); + return ( + + } + tooltip={tooltip} + step={1} + min={0} + /> + ); +}; + +export interface RelativeDeltaProps { + constraint: DateConstraintKey; +} + +const RelativeDelta: React.FC = ({constraint}) => { + return ( + <> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + ); +}; + +export default RelativeDelta; diff --git a/src/registry/datetime/validation/types.ts b/src/registry/datetime/validation/types.ts new file mode 100644 index 00000000..720d395b --- /dev/null +++ b/src/registry/datetime/validation/types.ts @@ -0,0 +1,13 @@ +import {DateTimeComponentSchema} from '@open-formulieren/types'; +import {DateConstraintConfiguration} from '@open-formulieren/types/lib/formio/dates'; + +import {FilterByValueType} from '@/types'; + +// A bunch of derived types from the DateTimeComponentSchema that makes working with the +// schema a bit more readable while keeping everything exhaustive and type safe. +type AllDateExtensions = Required>; + +export type AllModes = DateConstraintConfiguration['mode']; +export type NonEmptyModes = Exclude; +export type DateConstraintKey = keyof FilterByValueType; +export type AllPossibleConstraints = AllDateExtensions[DateConstraintKey]; diff --git a/src/registry/index.tsx b/src/registry/index.tsx index f6c5100d..970c4da6 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -1,6 +1,7 @@ import {AnyComponentSchema, FallbackSchema, hasOwnProperty} from '@/types'; import DateField from './date'; +import DateTimeField from './datetime'; import Email from './email'; import Fallback from './fallback'; import NumberField from './number'; @@ -26,6 +27,7 @@ const REGISTRY: Registry = { email: Email, number: NumberField, date: DateField, + datetime: DateTimeField, }; export {Fallback}; From 1a0346481fb76e8799d06ccfa7dc09372ec0fa7e Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 16:15:50 +0200 Subject: [PATCH 07/19] :test_tube: [#40] Add regression test for saving edit form --- .../ComponentConfiguration.stories.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index f4810a59..03e2d818 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -154,7 +154,7 @@ export const TextField: Story = { }, }, - play: async ({canvasElement}) => { + play: async ({canvasElement, args}) => { const canvas = within(canvasElement); await expect(canvas.getByLabelText('Label')).toHaveValue('A text field'); @@ -198,6 +198,9 @@ export const TextField: Story = { const addButtons = canvas.getAllByRole('button', {name: 'Add another'}); await userEvent.click(addButtons[0]); expect(await canvas.findByTestId('input-defaultValue[0]')); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); }, }; @@ -225,7 +228,7 @@ export const Email: Story = { }, }, - play: async ({canvasElement}) => { + play: async ({canvasElement, args}) => { const canvas = within(canvasElement); await expect(canvas.getByLabelText('Label')).toHaveValue('An email field'); @@ -281,6 +284,10 @@ export const Email: Story = { await waitFor(async () => { await expect(await canvas.findByText('Default Value must be a valid email.')).toBeVisible(); }); + + await userEvent.type(defaultInput0, '@example.com'); + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); }, }; @@ -308,7 +315,7 @@ export const NumberField: Story = { }, }, - play: async ({canvasElement}) => { + play: async ({canvasElement, args}) => { const canvas = within(canvasElement); await expect(canvas.getByLabelText('Label')).toHaveValue('A number field'); @@ -341,6 +348,9 @@ export const NumberField: Story = { 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 userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); }, }; @@ -368,7 +378,7 @@ export const DateField: Story = { }, }, - play: async ({canvasElement}) => { + play: async ({canvasElement, args}) => { const canvas = within(canvasElement); await expect(canvas.getByLabelText('Label')).toHaveValue('A date field'); @@ -419,6 +429,9 @@ export const DateField: Story = { await expect(defaultInput0.type).toEqual('date'); // userEvent.type does not reliably work with date input, and the native browser // datepicker helps in enforcing only valid dates. + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); }, }; @@ -446,7 +459,7 @@ export const DateTimeField: Story = { }, }, - play: async ({canvasElement}) => { + play: async ({canvasElement, args}) => { const canvas = within(canvasElement); await expect(canvas.getByLabelText('Label')).toHaveValue('A datetime field'); @@ -496,5 +509,8 @@ export const DateTimeField: Story = { const defaultInput0 = canvas.getByTestId('input-defaultValue[0]'); await expect(defaultInput0.type).toEqual('datetime-local'); // userEvent.type does not reliably work with datetime-local input + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); }, }; From 37871f0714215533c8f6e918e4261a9908f8bf9a Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 16:27:58 +0200 Subject: [PATCH 08/19] :bug: [#40] Fix validation displaying and validation itself in date/datetime component --- src/registry/date/edit-validation.ts | 3 ++- src/registry/date/edit.tsx | 4 +++- src/registry/datetime/edit-validation.ts | 3 ++- src/registry/datetime/edit.tsx | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/registry/date/edit-validation.ts b/src/registry/date/edit-validation.ts index 0369d508..f6e18dfc 100644 --- a/src/registry/date/edit-validation.ts +++ b/src/registry/date/edit-validation.ts @@ -17,7 +17,8 @@ const multipleValueSchema = z const defaultValueSchema = singleValueSchema.or(multipleValueSchema); -const noMode = z.object({mode: z.literal('')}); +// formik (deliberately) turns empty string into undefined +const noMode = z.object({mode: z.union([z.literal(undefined), z.literal('')])}); const future = z.object({ mode: z.literal('future'), includeToday: z.boolean(), diff --git a/src/registry/date/edit.tsx b/src/registry/date/edit.tsx index 9811d822..5bebe9e7 100644 --- a/src/registry/date/edit.tsx +++ b/src/registry/date/edit.tsx @@ -71,7 +71,9 @@ const EditForm: EditFormDefinition = () => { )} /> - + diff --git a/src/registry/datetime/edit-validation.ts b/src/registry/datetime/edit-validation.ts index 8c442a72..3a19d7b8 100644 --- a/src/registry/datetime/edit-validation.ts +++ b/src/registry/datetime/edit-validation.ts @@ -17,7 +17,8 @@ const multipleValueSchema = z const defaultValueSchema = singleValueSchema.or(multipleValueSchema); -const noMode = z.object({mode: z.literal('')}); +// formik (deliberately) turns empty string into undefined +const noMode = z.object({mode: z.union([z.literal(undefined), z.literal('')])}); const future = z.object({ mode: z.literal('future'), }); diff --git a/src/registry/datetime/edit.tsx b/src/registry/datetime/edit.tsx index 60e41899..f4f62d84 100644 --- a/src/registry/datetime/edit.tsx +++ b/src/registry/datetime/edit.tsx @@ -71,7 +71,9 @@ const EditForm: EditFormDefinition = () => { )} /> - + From 4f3cb322289b26c41b8929283609ab1efecc083a Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 16:29:40 +0200 Subject: [PATCH 09/19] :pencil: Mark datetime component as implemented --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a01a5a8..8a74b8e6 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The builder form is the form + preview shown in the edit component modal. - [ ] `currency` - [x] `email` - [x] `date` - - [ ] `datetime` + - [x] `datetime` - [ ] `time` - [ ] `phoneNumber` - [ ] `postcode` From ad1df2c50deae6da1501be8489e144e5f868df93 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 17:31:23 +0200 Subject: [PATCH 10/19] :green_heart: Update tests to be a bit more robust --- .../registration-attribute.stories.tsx | 15 ++++++--------- .../validation-datetimeconstraint.stories.ts | 4 ++-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/builder/registration/registration-attribute.stories.tsx b/src/components/builder/registration/registration-attribute.stories.tsx index 66dac4be..23885f33 100644 --- a/src/components/builder/registration/registration-attribute.stories.tsx +++ b/src/components/builder/registration/registration-attribute.stories.tsx @@ -45,20 +45,17 @@ export const Default: Story = { play: async ({canvasElement}) => { const canvas = within(canvasElement); - const input = await canvas.getByLabelText('Registration attribute'); + const input = canvas.getByLabelText('Registration attribute'); // open the dropdown - await input.focus(); + input.focus(); await userEvent.keyboard('[ArrowDown]'); + // wait for options to load await waitFor(async () => { - await expect(canvas.queryByText('Loading...')).toBeInTheDocument(); - }); - // assert the options are present - await waitFor(async () => { - await expect(canvas.queryByText('BSN')).toBeInTheDocument(); - await expect(canvas.queryByText('First name')).toBeInTheDocument(); - await expect(canvas.queryByText('Date of Birth')).toBeInTheDocument(); + expect(await canvas.findByText('BSN')).toBeVisible(); }); + expect(canvas.getByText('First name')).toBeVisible(); + expect(canvas.getByText('Date of Birth')).toBeVisible(); }, }; diff --git a/src/registry/datetime/validation-datetimeconstraint.stories.ts b/src/registry/datetime/validation-datetimeconstraint.stories.ts index a91beeed..d629bb4e 100644 --- a/src/registry/datetime/validation-datetimeconstraint.stories.ts +++ b/src/registry/datetime/validation-datetimeconstraint.stories.ts @@ -65,7 +65,7 @@ export const FixedValue: Story = { maxMode: 'year', yearRows: 4, yearColumns: 5, - minDate: '2023-01-01', + minDate: '2023-01-01T16:00', maxDate: null, }, }, @@ -81,7 +81,7 @@ export const FixedValue: Story = { expect(await canvas.findByText('Fixed value')).toBeVisible(); const datefield = await canvas.findByLabelText('Minimum date'); expect(datefield).toBeVisible(); - expect(datefield).toHaveDisplayValue('2023-01-01'); + expect(datefield).toHaveDisplayValue('2023-01-01T16:00'); }, }; From 206983be7c03194e17140a37716d045a13c0c361 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 20:19:54 +0200 Subject: [PATCH 11/19] :sparkles: Implement minimal time input component --- src/components/formio/index.ts | 1 + src/components/formio/timefield.stories.ts | 101 +++++++++++++++++++++ src/components/formio/timefield.tsx | 81 +++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/components/formio/timefield.stories.ts create mode 100644 src/components/formio/timefield.tsx diff --git a/src/components/formio/index.ts b/src/components/formio/index.ts index 9d7580d2..4b0e4288 100644 --- a/src/components/formio/index.ts +++ b/src/components/formio/index.ts @@ -17,6 +17,7 @@ export {default as TextField} from './textfield'; export {default as Checkbox} from './checkbox'; export {default as DateField} from './datefield'; export {default as DateTimeField} from './datetimefield'; +export {default as TimeField} from './timefield'; export {default as Panel} from './panel'; export {default as Select} from './select'; export {default as NumberField} from './number'; diff --git a/src/components/formio/timefield.stories.ts b/src/components/formio/timefield.stories.ts new file mode 100644 index 00000000..d6fd4eee --- /dev/null +++ b/src/components/formio/timefield.stories.ts @@ -0,0 +1,101 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {userEvent, within} from '@storybook/testing-library'; + +import {withFormik} from '@/sb-decorators'; + +import TimeField from './timefield'; + +export default { + title: 'Formio/Components/TimeField', + component: TimeField, + decorators: [withFormik], + parameters: { + modal: {noModal: true}, + formik: {initialValues: {'my-timefield': '12:00'}}, + }, + args: { + name: 'my-timefield', + }, +} as Meta; + +type Story = StoryObj; + +export const Required: Story = { + args: { + required: true, + label: 'A required timefield', + }, +}; + +export const WithoutLabel: Story = { + args: { + label: '', + }, +}; + +export const WithToolTip: Story = { + args: { + label: 'With tooltip', + tooltip: 'Hiya!', + required: false, + }, +}; + +export const Multiple: Story = { + args: { + label: 'Multiple inputs', + description: 'Array of times instead of a single time value', + multiple: true, + }, + + parameters: { + formik: { + initialValues: {'my-timefield': ['12:00']}, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-my-timefield[0]'); + await expect(input1).toHaveDisplayValue('12:00'); + + await userEvent.clear(input1); + await expect(input1).toHaveDisplayValue(''); + + // the label & description should be rendered only once, even with > 1 inputs + await expect(canvas.queryAllByText('Multiple inputs')).toHaveLength(1); + await expect( + canvas.queryAllByText('Array of times instead of a single time value') + ).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons).toHaveLength(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-my-timefield[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-my-timefield[1]')).not.toBeInTheDocument(); + }, +}; + +export const WithErrors: Story = { + args: { + label: 'With errors', + }, + + parameters: { + formik: { + initialValues: {'my-timefield': ''}, + initialErrors: {'my-timefield': 'Example error', 'other-field': 'Other error'}, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText('Other error')).not.toBeInTheDocument(); + await expect(canvas.queryByText('Example error')).toBeInTheDocument(); + }, +}; diff --git a/src/components/formio/timefield.tsx b/src/components/formio/timefield.tsx new file mode 100644 index 00000000..923d1a2f --- /dev/null +++ b/src/components/formio/timefield.tsx @@ -0,0 +1,81 @@ +import clsx from 'clsx'; +import {Field, useFormikContext} from 'formik'; +import {useContext} from 'react'; + +import {RenderContext} from '@/context'; +import {ErrorList, useValidationErrors} from '@/utils/errors'; + +import Component from './component'; +import Description from './description'; +import {withMultiple} from './multiple'; + +export interface TimeFieldProps { + name: string; + label?: React.ReactNode; + required?: boolean; + tooltip?: string; + description?: string; +} + +// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time +export const TimeField: React.FC = ({ + name, + label, + required = false, + tooltip = '', + description = '', + ...props +}) => { + const {getFieldProps} = useFormikContext(); + const {bareInput} = useContext(RenderContext); + const {errors, hasErrors} = useValidationErrors(name); + + const htmlId = `editform-${name}`; + + const {value} = getFieldProps(name); + + // let's not bother with date pickers - use the native browser date input instead. + const inputField = ( + + ); + + // 'bare input' is actually a little bit more than just the input, looking at the + // vanillay formio implementation. + if (bareInput) { + return ( + <> + {inputField} + + + ); + } + + // default-mode, wrapping the field with label, description etc. + return ( + +
{inputField}
+ {description && } +
+ ); +}; + +export const TimeFieldMultiple = withMultiple(TimeField, ''); +export default TimeFieldMultiple; From 630814d9a9cd8ee8ca1a89aa9959cd8a248766e0 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 17 Oct 2023 21:03:27 +0200 Subject: [PATCH 12/19] :sparkles: Implement builder form for time component --- .../ComponentConfiguration.stories.tsx | 28 +++ src/components/formio/datetimefield.tsx | 2 +- src/components/formio/timefield.tsx | 4 +- src/registry/index.tsx | 2 + src/registry/time/edit-validation.ts | 55 +++++ src/registry/time/edit.tsx | 228 ++++++++++++++++++ src/registry/time/index.ts | 10 + src/registry/time/preview.tsx | 40 +++ 8 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 src/registry/time/edit-validation.ts create mode 100644 src/registry/time/edit.tsx create mode 100644 src/registry/time/index.ts create mode 100644 src/registry/time/preview.tsx diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 03e2d818..7e4823df 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -514,3 +514,31 @@ export const DateTimeField: Story = { expect(args.onSubmit).toHaveBeenCalled(); }, }; + +export const TimeField: Story = { + render: Template, + name: 'type: time', + + args: { + component: { + id: 'wekruya', + type: 'time', + inputType: 'text', + format: 'HH:mm', + validateOn: 'blur', + key: 'time', + label: 'A time field', + validate: { + required: false, + }, + }, + + builderInfo: { + title: 'Time Field', + icon: 'clock-o', + group: 'basic', + weight: 10, + schema: {}, + }, + }, +}; diff --git a/src/components/formio/datetimefield.tsx b/src/components/formio/datetimefield.tsx index 1a602e07..4fac8475 100644 --- a/src/components/formio/datetimefield.tsx +++ b/src/components/formio/datetimefield.tsx @@ -64,7 +64,7 @@ export const DateTimeField: React.FC + z.string().refine( + value => { + const time24hFormat = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + return time24hFormat.test(value); + }, + { + message: intl.formatMessage({ + description: 'Error message for invalid 24h time input', + defaultMessage: 'The time must a valid time in the HH:mm format.', + }), + } + ); + +const buildOptionalTimeSchema = (intl: IntlShape) => + z.union([ + buildTime24hSchema(intl), + z.literal(''), + z.literal(undefined), // formik (deliberately) turns empty string into undefined + ]); + +// case for when component.multiple=false +const buildSingleValueSchema = (intl: IntlShape) => + z + .object({multiple: z.literal(false)}) + .and(z.object({defaultValue: buildOptionalTimeSchema(intl)})); + +// case for when component.multiple=true +const buildMultipleValueSchema = (intl: IntlShape) => + z + .object({multiple: z.literal(true)}) + .and(z.object({defaultValue: buildOptionalTimeSchema(intl).array()})); + +const buildTimeSpecific = (intl: IntlShape) => + z.object({ + validate: z + .object({ + minTime: buildOptionalTimeSchema(intl), + maxTime: buildOptionalTimeSchema(intl), + }) + .optional(), + }); + +const schema = (intl: IntlShape) => { + const commonSchema = buildCommonSchema(intl); + const defaultValueSchema = buildSingleValueSchema(intl).or(buildMultipleValueSchema(intl)); + return commonSchema.and(defaultValueSchema).and(buildTimeSpecific(intl)); +}; + +export default schema; diff --git a/src/registry/time/edit.tsx b/src/registry/time/edit.tsx new file mode 100644 index 00000000..b15216f3 --- /dev/null +++ b/src/registry/time/edit.tsx @@ -0,0 +1,228 @@ +import {TimeComponentSchema} from '@open-formulieren/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import { + BuilderTabs, + ClearOnHide, + Description, + Hidden, + IsSensitiveData, + Key, + Label, + Multiple, + PresentationConfig, + ReadOnly, + Registration, + SimpleConditional, + Tooltip, + Translations, + Validate, + useDeriveComponentKey, +} from '@/components/builder'; +import {LABELS} from '@/components/builder/messages'; +import {TabList, TabPanel, Tabs, TimeField} from '@/components/formio'; +import {EditFormDefinition} from '@/registry/types'; +import {getErrorNames} from '@/utils/errors'; + +/** + * Form to configure a Formio 'date' type component. + */ +const EditForm: EditFormDefinition = () => { + const intl = useIntl(); + const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); + const { + values: {multiple = false}, + errors, + } = useFormikContext(); + + 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', + 'minTime', + 'maxTime', + 'invalid_time', + ]); + + console.log(errors); + + 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), + }} + /> + + + ); +}; + +EditForm.defaultValues = { + format: 'HH:mm', + validateOn: 'blur', + inputType: 'text', + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + defaultValue: '', + disabled: false, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + minTime: '', + maxTime: '', + }, + translatedErrors: {}, + openForms: { + translations: {}, + }, + // Registration tab + registration: { + attribute: '', + }, +}; + +interface DefaultValueProps { + multiple: boolean; +} + +const DefaultValue: React.FC = ({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 ( + } + tooltip={tooltip} + multiple={multiple} + /> + ); +}; + +const MinTime: React.FC = () => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'validate.minTime' builder field", + defaultMessage: 'The earliest possible value that can be entered.', + }); + return ( + + } + tooltip={tooltip} + /> + ); +}; + +const MaxTime: React.FC = () => { + const intl = useIntl(); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'validate.maxTime' builder field", + defaultMessage: 'The latest possible value that can be entered.', + }); + return ( + + } + tooltip={tooltip} + /> + ); +}; + +export default EditForm; diff --git a/src/registry/time/index.ts b/src/registry/time/index.ts new file mode 100644 index 00000000..f636dcf3 --- /dev/null +++ b/src/registry/time/index.ts @@ -0,0 +1,10 @@ +import EditForm from './edit'; +import validationSchema from './edit-validation'; +import Preview from './preview'; + +export default { + edit: EditForm, + editSchema: validationSchema, + preview: Preview, + defaultValue: '', +}; diff --git a/src/registry/time/preview.tsx b/src/registry/time/preview.tsx new file mode 100644 index 00000000..1efc3b13 --- /dev/null +++ b/src/registry/time/preview.tsx @@ -0,0 +1,40 @@ +import {TimeComponentSchema} from '@open-formulieren/types'; + +import {TimeField} from '@/components/formio'; + +import {ComponentPreviewProps} from '../types'; + +/** + * Show a formio time component preview. + * + * NOTE: for the time being, this is rendered in the default Formio bootstrap style, + * however at some point this should use the components of + * @open-formulieren/formio-renderer instead for a more accurate preview. + */ +const Preview: React.FC> = ({component}) => { + const { + key, + label, + description, + placeholder, + tooltip, + validate = {}, + disabled = false, + multiple = false, + } = component; + const {required = false} = validate; + return ( + + ); +}; + +export default Preview; From 3613286230b6f6976e70e67b316624e57d6de683 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 18 Oct 2023 11:16:18 +0200 Subject: [PATCH 13/19] :white_check_mark: Add stories/interaction tests --- .../ComponentConfiguration.stories.tsx | 55 +++++++++++++++ src/components/ComponentPreview.stories.tsx | 69 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 7e4823df..9e276fe2 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -541,4 +541,59 @@ export const TimeField: Story = { schema: {}, }, }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A time field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aTimeField'); + }); + await expect(canvas.getByLabelText('Description')).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(); + await expect(canvas.queryByLabelText('Placeholder')).not.toBeInTheDocument(); + + // ensure that changing fields in the edit form properly update the preview + const preview = within(canvas.getByTestId('componentPreview')); + + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label'); + expect(await preview.findByText('Updated preview label')); + + const previewInput = preview.getByLabelText('Updated preview label'); + await expect(previewInput).toHaveDisplayValue(''); + await expect(previewInput.type).toEqual('time'); + + // 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.change(keyInput, {target: {value: 'customKey'}}); + await userEvent.click(canvas.getByRole('tab', {name: 'Advanced'})); + 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'); + + // check that toggling the 'multiple' checkbox properly updates the preview and default + // value field + await userEvent.click(canvas.getByLabelText('Multiple values')); + await userEvent.click(preview.getByRole('button', {name: 'Add another'})); + // await expect(preview.getByTestId('input-customKey[0]')).toHaveDisplayValue(''); + // test for the default value inputs -> these don't have accessible labels/names :( + const addButtons = canvas.getAllByRole('button', {name: 'Add another'}); + await userEvent.click(addButtons[0]); + await waitFor(async () => { + await expect(await canvas.findByTestId('input-defaultValue[0]')).toBeVisible(); + }); + + // check that default value is e-mail validated + const defaultInput0 = canvas.getByTestId('input-defaultValue[0]'); + await expect(defaultInput0.type).toEqual('time'); + // userEvent.type does not reliably work with time input + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); + }, }; diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 9e00700b..6c09b1f5 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -374,3 +374,72 @@ export const DateTimeFieldMultiple: Story = { await expect(canvas.queryByTestId('input-datetimePreview[1]')).not.toBeInTheDocument(); }, }; + +export const TimeField: Story = { + render: Template, + + args: { + component: { + type: 'time', + id: 'time', + key: 'timePreview', + label: 'Time preview', + description: 'A preview of the time Formio component', + hidden: true, // must be ignored + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Time preview'); + await canvas.findByText('A preview of the time Formio component'); + + // check that the input name is set correctly + const input = canvas.getByLabelText('Time preview'); + // @ts-ignore + await expect(input.getAttribute('name')).toBe(args.component.key); + + // typing into native time inputs is not reliable, so no such checks here + }, +}; + +export const TimeFieldMultiple: Story = { + render: Template, + + args: { + component: { + type: 'time', + id: 'time', + key: 'timePreview', + label: 'Time preview', + description: 'Description only once', + hidden: true, // must be ignored + multiple: true, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-timePreview[0]'); + await expect(input1).toHaveDisplayValue(''); + await expect(input1.type).toEqual('time'); + + // the description should be rendered only once, even with > 1 inputs + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input2 = canvas.getByTestId('input-timePreview[1]'); + await expect(input2).toHaveDisplayValue(''); + await expect(canvas.queryAllByText('Description only once')).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons.length).toBe(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-timePreview[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-timePreview[1]')).not.toBeInTheDocument(); + }, +}; From 7aaf10ccd20066921ea0e4831fe5eebce2e6c191 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 18 Oct 2023 13:25:35 +0200 Subject: [PATCH 14/19] :sparkles: Pretend that inputMask is supported inputMask is used under the hood by the postcode component. The formio builder implementation stops at the placeholder. Since these components are not actually used in the renderer/SDK and are only set up for preview purposes and it's *too much* work to get the masking hooked up, I've decided to let it be. --- src/components/formio/textfield.stories.tsx | 32 +++++++++++++++++++++ src/components/formio/textfield.tsx | 13 +++++++++ src/utils/inputmask.ts | 23 +++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/utils/inputmask.ts diff --git a/src/components/formio/textfield.stories.tsx b/src/components/formio/textfield.stories.tsx index d02f2f4b..04a383c0 100644 --- a/src/components/formio/textfield.stories.tsx +++ b/src/components/formio/textfield.stories.tsx @@ -103,3 +103,35 @@ export const WithErrors: Story = { await expect(canvas.queryByText('Example error')).toBeInTheDocument(); }, }; + +export const WithMask: Story = { + args: { + label: 'With mask', + inputMask: '9999 AA', + }, + + parameters: { + formik: { + initialValues: {'my-textfield': ''}, + }, + }, + + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + const input = canvas.getByLabelText('With mask'); + + await step('Empty input shows placeholder', async () => { + expect(input).toHaveDisplayValue(''); + expect(input).toHaveAttribute('placeholder', '____ __'); + }); + + await step('Typing into input', async () => { + await userEvent.type(input, '1015'); + // with formio's masking enabled, this would be '1015 __', but we're skipping + // that messy implementation for the form builder. At some point we should be + // able to re-use renderer components that fully implement the behaviour in an + // accessible manner. + expect(input).toHaveDisplayValue('1015'); + }); + }, +}; diff --git a/src/components/formio/textfield.tsx b/src/components/formio/textfield.tsx index 8313df35..da3c2058 100644 --- a/src/components/formio/textfield.tsx +++ b/src/components/formio/textfield.tsx @@ -5,6 +5,7 @@ import {useContext, useRef} from 'react'; import {RenderContext} from '@/context'; import CharCount from '@/utils/charcount'; import {ErrorList, useValidationErrors} from '@/utils/errors'; +import {applyInputMask} from '@/utils/inputmask'; import Component from './component'; import Description from './description'; @@ -17,6 +18,7 @@ export interface TextFieldProps { tooltip?: string; description?: string; showCharCount?: boolean; + inputMask?: string; } export const TextField: React.FC = ({ @@ -26,6 +28,7 @@ export const TextField: React.FC { const {getFieldProps, getFieldMeta} = useFormikContext(); @@ -41,6 +44,16 @@ export const TextField: React.FC { + const options = {placeholderChar: '_'}; + const result = conformToMask(textValue, Utils.getInputMask(mask), options); + return result.conformedValue; +}; From a229c08ed0e53986b47a60c68dcb3edf6959aada Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 18 Oct 2023 15:29:19 +0200 Subject: [PATCH 15/19] :sparkles: Implement preview of postcode component --- src/components/ComponentPreview.stories.tsx | 77 +++++++++++++++++++++ src/registry/index.tsx | 2 + src/registry/postcode/index.ts | 8 +++ src/registry/postcode/preview.tsx | 51 ++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 src/registry/postcode/index.ts create mode 100644 src/registry/postcode/preview.tsx diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 6c09b1f5..05965ea9 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -443,3 +443,80 @@ export const TimeFieldMultiple: Story = { await expect(canvas.queryByTestId('input-timePreview[1]')).not.toBeInTheDocument(); }, }; + +export const Postcode: Story = { + name: 'Postcode (deprecated)', + render: Template, + + args: { + component: { + type: 'postcode', + id: 'postcode', + key: 'postcodePreview', + label: 'Postcode preview', + description: 'A preview of the postcode Formio component', + hidden: true, // must be ignored + inputMask: '9999 AA', + validate: { + pattern: '^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$', + }, + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Postcode preview'); + await canvas.findByText('A preview of the postcode Formio component'); + + // check that the input name is set correctly + const input = canvas.getByLabelText('Postcode preview'); + // @ts-ignore + await expect(input.getAttribute('name')).toBe(args.component.key); + + expect(input).toHaveAttribute('placeholder', '____ __'); + await userEvent.type(input, '1015 CJ'); + expect(input).toHaveDisplayValue('1015 CJ'); + }, +}; + +export const PostcodeMultiple: Story = { + name: 'Postcode (deprecated) Multiple', + render: Template, + + args: { + component: { + type: 'postcode', + id: 'postcode', + key: 'postcodePreview', + label: 'Postcode preview', + description: 'Description only once', + hidden: true, // must be ignored + multiple: true, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-postcodePreview[0]'); + await expect(input1).toHaveDisplayValue(''); + await expect(input1.type).toEqual('text'); + + // the description should be rendered only once, even with > 1 inputs + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input2 = canvas.getByTestId('input-postcodePreview[1]'); + await expect(input2).toHaveDisplayValue(''); + await expect(canvas.queryAllByText('Description only once')).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons.length).toBe(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-postcodePreview[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-postcodePreview[1]')).not.toBeInTheDocument(); + }, +}; diff --git a/src/registry/index.tsx b/src/registry/index.tsx index 19d1ae3a..9c60a6ff 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -5,6 +5,7 @@ import DateTimeField from './datetime'; import Email from './email'; import Fallback from './fallback'; import NumberField from './number'; +import Postcode from './postcode'; import TextField from './textfield'; import TimeField from './time'; import {Registry, RegistryEntry} from './types'; @@ -30,6 +31,7 @@ const REGISTRY: Registry = { date: DateField, datetime: DateTimeField, time: TimeField, + postcode: Postcode, }; export {Fallback}; diff --git a/src/registry/postcode/index.ts b/src/registry/postcode/index.ts new file mode 100644 index 00000000..bf443ce5 --- /dev/null +++ b/src/registry/postcode/index.ts @@ -0,0 +1,8 @@ +import Preview from './preview'; + +export default { + // edit: EditForm, + // editSchema: validationSchema, + preview: Preview, + defaultValue: '', +}; diff --git a/src/registry/postcode/preview.tsx b/src/registry/postcode/preview.tsx new file mode 100644 index 00000000..05210a7f --- /dev/null +++ b/src/registry/postcode/preview.tsx @@ -0,0 +1,51 @@ +import {PostcodeComponentSchema} from '@open-formulieren/types'; + +import {TextField} from '@/components/formio'; + +import {ComponentPreviewProps} from '../types'; + +const defaultValidate = { + required: false, + pattern: '^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$', +}; + +/** + * Show a formio postcode component preview. + * + * @deprecated - The custom component type is deprecated in favour of a text + * field-based preset. + * + * NOTE: for the time being, this is rendered in the default Formio bootstrap style, + * however at some point this should use the components of + * @open-formulieren/formio-renderer instead for a more accurate preview. + */ +const Preview: React.FC> = ({component}) => { + const { + key, + label, + description, + tooltip, + validate = defaultValidate, + autocomplete = '', + disabled = false, + multiple, + inputMask, + } = component; + const {required = false, pattern} = validate; + return ( + + ); +}; + +export default Preview; From 81122ff0a4b351be6f63f34a5663d0b7b0879938 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 18 Oct 2023 16:24:51 +0200 Subject: [PATCH 16/19] :sparkles: Implement postcode component edit form --- README.md | 2 +- .../ComponentConfiguration.stories.tsx | 82 ++++++++ src/registry/postcode/constants.ts | 1 + src/registry/postcode/edit-validation.ts | 45 ++++ src/registry/postcode/edit.tsx | 199 ++++++++++++++++++ src/registry/postcode/index.ts | 6 +- src/registry/postcode/preview.tsx | 3 +- 7 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 src/registry/postcode/constants.ts create mode 100644 src/registry/postcode/edit-validation.ts create mode 100644 src/registry/postcode/edit.tsx diff --git a/README.md b/README.md index 8a74b8e6..6115d675 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ The builder form is the form + preview shown in the edit component modal. - [x] `datetime` - [ ] `time` - [ ] `phoneNumber` - - [ ] `postcode` + - [x] `postcode` - [ ] `file` - [ ] `iban` - [ ] `licenseplate` diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 9e276fe2..0ea4534d 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -597,3 +597,85 @@ export const TimeField: Story = { expect(args.onSubmit).toHaveBeenCalled(); }, }; + +export const Postcode: Story = { + render: Template, + name: 'type: postcode (deprecated)', + + args: { + component: { + id: 'wekruya', + type: 'postcode', + validateOn: 'blur', + inputMask: '9999 AA', + key: 'postcode', + label: 'A postcode field', + validate: { + required: false, + pattern: '^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$', + }, + }, + + builderInfo: { + title: 'Postcode', + icon: 'home', + group: 'basic', + weight: 10, + schema: {}, + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A postcode field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aPostcodeField'); + }); + await expect(canvas.getByLabelText('Description')).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(); + await expect(canvas.queryByLabelText('Placeholder')).not.toBeInTheDocument(); + + // ensure that changing fields in the edit form properly update the preview + const preview = within(canvas.getByTestId('componentPreview')); + + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label'); + expect(await preview.findByText('Updated preview label')); + + const previewInput = preview.getByLabelText('Updated preview label'); + await expect(previewInput).toHaveDisplayValue(''); + await expect(previewInput.type).toEqual('text'); + + // 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.change(keyInput, {target: {value: 'customKey'}}); + await userEvent.click(canvas.getByRole('tab', {name: 'Advanced'})); + 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'); + + // check that toggling the 'multiple' checkbox properly updates the preview and default + // value field + await userEvent.click(canvas.getByLabelText('Multiple values')); + await userEvent.click(preview.getByRole('button', {name: 'Add another'})); + // await expect(preview.getByTestId('input-customKey[0]')).toHaveDisplayValue(''); + // test for the default value inputs -> these don't have accessible labels/names :( + const addButtons = canvas.getAllByRole('button', {name: 'Add another'}); + await userEvent.click(addButtons[0]); + await waitFor(async () => { + await expect(await canvas.findByTestId('input-defaultValue[0]')).toBeVisible(); + }); + + // check that default value is e-mail validated + const defaultInput0 = canvas.getByTestId('input-defaultValue[0]'); + await expect(defaultInput0.type).toEqual('text'); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); + }, +}; diff --git a/src/registry/postcode/constants.ts b/src/registry/postcode/constants.ts new file mode 100644 index 00000000..2a1d76e7 --- /dev/null +++ b/src/registry/postcode/constants.ts @@ -0,0 +1 @@ +export const POSTCODE_REGEX = '^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$'; diff --git a/src/registry/postcode/edit-validation.ts b/src/registry/postcode/edit-validation.ts new file mode 100644 index 00000000..60a9da0b --- /dev/null +++ b/src/registry/postcode/edit-validation.ts @@ -0,0 +1,45 @@ +import {IntlShape, defineMessages} from 'react-intl'; +import {z} from 'zod'; + +import {LABELS} from '@/components/builder/messages'; +import {buildCommonSchema, getErrorMap, isInvalidStringIssue} from '@/registry/validation'; + +import {POSTCODE_REGEX} from './constants'; + +const VALIDATION_MESSAGES = defineMessages({ + email: { + description: 'Invalid postcode format validation error', + defaultMessage: '{field} must be a valid postcode.', + }, +}); + +const buildDefaultValueSchema = (intl: IntlShape) => { + const postcodeSchema = z + .string({ + errorMap: getErrorMap(issue => { + if (isInvalidStringIssue(issue) && issue.validation === 'regex') { + const fieldLabel = intl.formatMessage(LABELS.defaultValue); + return intl.formatMessage(VALIDATION_MESSAGES.email, {field: fieldLabel}); + } + return; + }), + }) + .regex(new RegExp(POSTCODE_REGEX)) + .optional(); + + // case for when component.multiple=false + const singleValueSchema = z + .object({multiple: z.literal(false)}) + .and(z.object({defaultValue: postcodeSchema})); + + // case for when component.multiple=true + const multipleValueSchema = z + .object({multiple: z.literal(true)}) + .and(z.object({defaultValue: postcodeSchema.array()})); + + return singleValueSchema.or(multipleValueSchema); +}; + +const schema = (intl: IntlShape) => buildCommonSchema(intl).and(buildDefaultValueSchema(intl)); + +export default schema; diff --git a/src/registry/postcode/edit.tsx b/src/registry/postcode/edit.tsx new file mode 100644 index 00000000..4efd1aba --- /dev/null +++ b/src/registry/postcode/edit.tsx @@ -0,0 +1,199 @@ +import {PostcodeComponentSchema} from '@open-formulieren/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import { + BuilderTabs, + ClearOnHide, + Description, + Hidden, + IsSensitiveData, + Key, + Label, + Multiple, + Prefill, + PresentationConfig, + ReadOnly, + Registration, + SimpleConditional, + Tooltip, + Translations, + Validate, + useDeriveComponentKey, +} from '@/components/builder'; +import {LABELS} from '@/components/builder/messages'; +import {TabList, TabPanel, Tabs, TextField} from '@/components/formio'; +import {getErrorNames} from '@/utils/errors'; + +import {EditFormDefinition} from '../types'; +import {POSTCODE_REGEX} from './constants'; + +/** + * Form to configure a Formio 'textfield' type component. + */ +const EditForm: EditFormDefinition = () => { + const intl = useIntl(); + const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); + const {values, errors} = useFormikContext(); + + 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', 'pattern']); + return ( + + + + + + + + + + + {/* Basic tab */} + + + + {/* Advanced tab */} + + + + + {/* Validation tab */} + + + + + + + {/* Registration tab */} + + + + + {/* Prefill tab */} + + + + + {/* Translations */} + + + propertyLabels={{ + label: intl.formatMessage(LABELS.label), + description: intl.formatMessage(LABELS.description), + tooltip: intl.formatMessage(LABELS.tooltip), + }} + /> + + + ); +}; + +/* + Making this introspected or declarative doesn't seem advisable, as React is calling + React.Children and related API's legacy API - this may get removed in future + versions. + + Explicitly specifying the schema and default values is therefore probbaly best, at + the cost of some repetition. + */ +EditForm.defaultValues = { + validateOn: 'blur', + inputMask: '9999 AA', + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: true, + defaultValue: '', + autocomplete: '', + disabled: false, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + pattern: POSTCODE_REGEX, + }, + translatedErrors: {}, + registration: { + attribute: '', + }, + prefill: { + plugin: null, + attribute: null, + identifierRole: 'main', + }, +}; + +interface DefaultValueProps { + multiple: boolean; +} + +const DefaultValue: React.FC = ({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 ( + } + tooltip={tooltip} + multiple={multiple} + inputMask="9999 AA" + /> + ); +}; + +export default EditForm; diff --git a/src/registry/postcode/index.ts b/src/registry/postcode/index.ts index bf443ce5..f636dcf3 100644 --- a/src/registry/postcode/index.ts +++ b/src/registry/postcode/index.ts @@ -1,8 +1,10 @@ +import EditForm from './edit'; +import validationSchema from './edit-validation'; import Preview from './preview'; export default { - // edit: EditForm, - // editSchema: validationSchema, + edit: EditForm, + editSchema: validationSchema, preview: Preview, defaultValue: '', }; diff --git a/src/registry/postcode/preview.tsx b/src/registry/postcode/preview.tsx index 05210a7f..97c2b849 100644 --- a/src/registry/postcode/preview.tsx +++ b/src/registry/postcode/preview.tsx @@ -3,10 +3,11 @@ import {PostcodeComponentSchema} from '@open-formulieren/types'; import {TextField} from '@/components/formio'; import {ComponentPreviewProps} from '../types'; +import {POSTCODE_REGEX} from './constants'; const defaultValidate = { required: false, - pattern: '^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[a-zA-Z]{2}$', + pattern: POSTCODE_REGEX, }; /** From 4d13f6a2d721fb290fff1778d549951327896ff0 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 19 Oct 2023 20:01:08 +0200 Subject: [PATCH 17/19] :sparkles: Implement phone number preview component --- src/components/ComponentPreview.stories.tsx | 72 +++++++++++++++++++++ src/registry/index.tsx | 2 + src/registry/phonenumber/index.ts | 10 +++ src/registry/phonenumber/preview.tsx | 43 ++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/registry/phonenumber/index.ts create mode 100644 src/registry/phonenumber/preview.tsx diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 05965ea9..a657dcfa 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -520,3 +520,75 @@ export const PostcodeMultiple: Story = { await expect(canvas.queryByTestId('input-postcodePreview[1]')).not.toBeInTheDocument(); }, }; + +export const PhoneNumber: Story = { + name: 'PhoneNumber', + render: Template, + + args: { + component: { + type: 'phoneNumber', + id: 'phoneNumber', + key: 'phoneNumber', + label: 'Phone number preview', + description: 'A preview of the phoneNumber Formio component', + hidden: true, // must be ignored + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Phone number preview'); + await canvas.findByText('A preview of the phoneNumber Formio component'); + + // check that the input name is set correctly + const input = canvas.getByLabelText('Phone number preview'); + // @ts-ignore + await expect(input.getAttribute('name')).toBe(args.component.key); + + await userEvent.type(input, '+316 12345678'); + expect(input).toHaveDisplayValue('+316 12345678'); + }, +}; + +export const PhoneNumberMultiple: Story = { + name: 'PhoneNumber Multiple', + render: Template, + + args: { + component: { + type: 'phoneNumber', + id: 'phoneNumber', + key: 'phoneNumberPreview', + label: 'Phone number preview', + description: 'Description only once', + hidden: true, // must be ignored + multiple: true, + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + // check that new items can be added + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input1 = canvas.getByTestId('input-phoneNumberPreview[0]'); + await expect(input1).toHaveDisplayValue(''); + await expect(input1.type).toEqual('text'); + + // the description should be rendered only once, even with > 1 inputs + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const input2 = canvas.getByTestId('input-phoneNumberPreview[1]'); + await expect(input2).toHaveDisplayValue(''); + await expect(canvas.queryAllByText('Description only once')).toHaveLength(1); + + // finally, it should be possible delete rows again + const removeButtons = await canvas.findAllByRole('button', {name: 'Remove item'}); + await expect(removeButtons.length).toBe(2); + await userEvent.click(removeButtons[0]); + await expect(canvas.getByTestId('input-phoneNumberPreview[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-phoneNumberPreview[1]')).not.toBeInTheDocument(); + }, +}; diff --git a/src/registry/index.tsx b/src/registry/index.tsx index 9c60a6ff..6f9f9456 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -5,6 +5,7 @@ import DateTimeField from './datetime'; import Email from './email'; import Fallback from './fallback'; import NumberField from './number'; +import PhoneNumber from './phonenumber'; import Postcode from './postcode'; import TextField from './textfield'; import TimeField from './time'; @@ -31,6 +32,7 @@ const REGISTRY: Registry = { date: DateField, datetime: DateTimeField, time: TimeField, + phoneNumber: PhoneNumber, postcode: Postcode, }; diff --git a/src/registry/phonenumber/index.ts b/src/registry/phonenumber/index.ts new file mode 100644 index 00000000..50c81847 --- /dev/null +++ b/src/registry/phonenumber/index.ts @@ -0,0 +1,10 @@ +// import EditForm from './edit'; +// import validationSchema from './edit-validation'; +import Preview from './preview'; + +export default { + // edit: EditForm, + // editSchema: validationSchema, + preview: Preview, + defaultValue: '', +}; diff --git a/src/registry/phonenumber/preview.tsx b/src/registry/phonenumber/preview.tsx new file mode 100644 index 00000000..4fd8619c --- /dev/null +++ b/src/registry/phonenumber/preview.tsx @@ -0,0 +1,43 @@ +import {PhoneNumberComponentSchema} from '@open-formulieren/types'; + +import {TextField} from '@/components/formio'; + +import {ComponentPreviewProps} from '../types'; + +/** + * Show a formio textfield component preview. + * + * NOTE: for the time being, this is rendered in the default Formio bootstrap style, + * however at some point this should use the components of + * @open-formulieren/formio-renderer instead for a more accurate preview. + */ +const Preview: React.FC> = ({component}) => { + const { + key, + label, + description, + placeholder, + tooltip, + validate = {}, + autocomplete = '', + disabled = false, + multiple, + } = component; + const {required = false} = validate; + return ( + + ); +}; + +export default Preview; From a041337f5de0dce5b07014caee669d6a16ab7fb8 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 19 Oct 2023 20:08:19 +0200 Subject: [PATCH 18/19] :sparkles: Implement edit form for phoneNumber component --- .../ComponentConfiguration.stories.tsx | 23 +++ src/registry/phonenumber/edit-validation.ts | 7 + src/registry/phonenumber/edit.tsx | 187 ++++++++++++++++++ src/registry/phonenumber/index.ts | 8 +- 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/registry/phonenumber/edit-validation.ts create mode 100644 src/registry/phonenumber/edit.tsx diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 0ea4534d..37a1cd0c 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -679,3 +679,26 @@ export const Postcode: Story = { expect(args.onSubmit).toHaveBeenCalled(); }, }; + +export const PhoneNumber: Story = { + render: Template, + name: 'type: phoneNumber', + + args: { + component: { + id: 'wekruya', + type: 'phoneNumber', + inputMask: null, + key: 'phoneNumber', + label: 'A phone number field', + }, + + builderInfo: { + title: 'Phone number', + icon: 'phone-square', + group: 'basic', + weight: 10, + schema: {}, + }, + }, +}; diff --git a/src/registry/phonenumber/edit-validation.ts b/src/registry/phonenumber/edit-validation.ts new file mode 100644 index 00000000..1cb4c6eb --- /dev/null +++ b/src/registry/phonenumber/edit-validation.ts @@ -0,0 +1,7 @@ +import {IntlShape} from 'react-intl'; + +import {buildCommonSchema} from '@/registry/validation'; + +const schema = (intl: IntlShape) => buildCommonSchema(intl); + +export default schema; diff --git a/src/registry/phonenumber/edit.tsx b/src/registry/phonenumber/edit.tsx new file mode 100644 index 00000000..9088578a --- /dev/null +++ b/src/registry/phonenumber/edit.tsx @@ -0,0 +1,187 @@ +import {PhoneNumberComponentSchema} from '@open-formulieren/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import { + AutoComplete, + BuilderTabs, + ClearOnHide, + Description, + Hidden, + IsSensitiveData, + Key, + Label, + Multiple, + PresentationConfig, + Registration, + SimpleConditional, + Tooltip, + Translations, + Validate, + useDeriveComponentKey, +} from '@/components/builder'; +import {LABELS} from '@/components/builder/messages'; +import {TabList, TabPanel, Tabs, TextField} from '@/components/formio'; +import {getErrorNames} from '@/utils/errors'; + +import {EditFormDefinition} from '../types'; + +/** + * Form to configure a Formio 'postcode' type component. + * + * @todo - replace with a preset of textfield? + */ +const EditForm: EditFormDefinition = () => { + const intl = useIntl(); + const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); + const {values, errors} = useFormikContext(); + + 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', 'pattern']); + 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), + }} + /> + + + ); +}; + +/* + Making this introspected or declarative doesn't seem advisable, as React is calling + React.Children and related API's legacy API - this may get removed in future + versions. + + Explicitly specifying the schema and default values is therefore probbaly best, at + the cost of some repetition. + */ +EditForm.defaultValues = { + inputMask: null, + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: true, + defaultValue: '', + autocomplete: 'tel', + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + pattern: '', + }, + translatedErrors: {}, + registration: { + attribute: '', + }, +}; + +interface DefaultValueProps { + multiple: boolean; +} + +const DefaultValue: React.FC = ({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 ( + } + tooltip={tooltip} + multiple={multiple} + inputMode="decimal" + /> + ); +}; + +export default EditForm; diff --git a/src/registry/phonenumber/index.ts b/src/registry/phonenumber/index.ts index 50c81847..f636dcf3 100644 --- a/src/registry/phonenumber/index.ts +++ b/src/registry/phonenumber/index.ts @@ -1,10 +1,10 @@ -// import EditForm from './edit'; -// import validationSchema from './edit-validation'; +import EditForm from './edit'; +import validationSchema from './edit-validation'; import Preview from './preview'; export default { - // edit: EditForm, - // editSchema: validationSchema, + edit: EditForm, + editSchema: validationSchema, preview: Preview, defaultValue: '', }; From d3fb047a5e5100accab5eea58fdad8e257786d52 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 20 Oct 2023 10:51:24 +0200 Subject: [PATCH 19/19] :white_check_mark: Add interaction test for entire phoneNumber builder form --- README.md | 4 +- .../ComponentConfiguration.stories.tsx | 48 +++++++++++++++++++ src/registry/phonenumber/edit.tsx | 2 +- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6115d675..0258f940 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ The builder form is the form + preview shown in the edit component modal. - [x] `email` - [x] `date` - [x] `datetime` - - [ ] `time` - - [ ] `phoneNumber` + - [x] `time` + - [x] `phoneNumber` - [x] `postcode` - [ ] `file` - [ ] `iban` diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 37a1cd0c..c3cbbad0 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -701,4 +701,52 @@ export const PhoneNumber: Story = { schema: {}, }, }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A phone number field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aPhoneNumberField'); + }); + 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 + const preview = within(canvas.getByTestId('componentPreview')); + + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label'); + expect(await preview.findByText('Updated preview label')); + + const previewInput = preview.getByLabelText('Updated preview label'); + await expect(previewInput).toHaveDisplayValue(''); + + // 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'); + + // check that toggling the 'multiple' checkbox properly updates the preview and default + // value field + await userEvent.click(canvas.getByLabelText('Multiple values')); + await userEvent.click(preview.getByRole('button', {name: 'Add another'})); + await expect(preview.getByTestId('input-customKey[0]')).toHaveDisplayValue(''); + // test for the default value inputs -> these don't have accessible labels/names :( + const addButtons = canvas.getAllByRole('button', {name: 'Add another'}); + await userEvent.click(addButtons[0]); + expect(await canvas.findByTestId('input-defaultValue[0]')); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalled(); + }, }; diff --git a/src/registry/phonenumber/edit.tsx b/src/registry/phonenumber/edit.tsx index 9088578a..0d47aa79 100644 --- a/src/registry/phonenumber/edit.tsx +++ b/src/registry/phonenumber/edit.tsx @@ -27,7 +27,7 @@ import {getErrorNames} from '@/utils/errors'; import {EditFormDefinition} from '../types'; /** - * Form to configure a Formio 'postcode' type component. + * Form to configure a Formio 'phoneNumber' type component. * * @todo - replace with a preset of textfield? */