diff --git a/README.md b/README.md index bae1ec60..0258f940 100644 --- a/README.md +++ b/README.md @@ -121,14 +121,14 @@ The builder form is the form + preview shown in the edit component modal. - [ ] `selectboxes` - [ ] `select` - [ ] `radio` - - [ ] `number` + - [x] `number` - [ ] `currency` - [x] `email` - - [ ] `date` - - [ ] `datetime` - - [ ] `time` - - [ ] `phoneNumber` - - [ ] `postcode` + - [x] `date` + - [x] `datetime` + - [x] `time` + - [x] `phoneNumber` + - [x] `postcode` - [ ] `file` - [ ] `iban` - [ ] `licenseplate` 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", diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 712b4189..c3cbbad0 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,5 +429,324 @@ 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(); + }, +}; + +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, args}) => { + 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 + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + 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: {}, + }, + }, + + 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(); + }, +}; + +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(); + }, +}; + +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: {}, + }, + }, + + 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/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index 57cd164e..a657dcfa 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -236,3 +236,359 @@ 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(); + }, +}; + +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(); + }, +}; + +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(); + }, +}; + +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(); + }, +}; + +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/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/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..4fac8475 --- /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; diff --git a/src/components/formio/index.ts b/src/components/formio/index.ts index 3cc93ba1..4b0e4288 100644 --- a/src/components/formio/index.ts +++ b/src/components/formio/index.ts @@ -16,6 +16,8 @@ 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 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/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; + +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..c8ec909b --- /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; 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/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..3a19d7b8 --- /dev/null +++ b/src/registry/datetime/edit-validation.ts @@ -0,0 +1,54 @@ +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); + +// 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'), +}); +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..f4f62d84 --- /dev/null +++ b/src/registry/datetime/edit.tsx @@ -0,0 +1,207 @@ +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..d629bb4e --- /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-01T16:00', + 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-01T16:00'); + }, +}; + +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..6f9f9456 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -1,10 +1,14 @@ 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'; +import PhoneNumber from './phonenumber'; +import Postcode from './postcode'; import TextField from './textfield'; +import TimeField from './time'; import {Registry, RegistryEntry} from './types'; export const isKnownComponentType = ( @@ -26,6 +30,10 @@ const REGISTRY: Registry = { email: Email, number: NumberField, date: DateField, + datetime: DateTimeField, + time: TimeField, + phoneNumber: PhoneNumber, + postcode: Postcode, }; export {Fallback}; 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..0d47aa79 --- /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 'phoneNumber' 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 new file mode 100644 index 00000000..f636dcf3 --- /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; 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 new file mode 100644 index 00000000..f636dcf3 --- /dev/null +++ b/src/registry/postcode/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/postcode/preview.tsx b/src/registry/postcode/preview.tsx new file mode 100644 index 00000000..97c2b849 --- /dev/null +++ b/src/registry/postcode/preview.tsx @@ -0,0 +1,52 @@ +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: POSTCODE_REGEX, +}; + +/** + * 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; diff --git a/src/registry/time/edit-validation.ts b/src/registry/time/edit-validation.ts new file mode 100644 index 00000000..5e89dbed --- /dev/null +++ b/src/registry/time/edit-validation.ts @@ -0,0 +1,55 @@ +import {IntlShape} from 'react-intl'; +import {z} from 'zod'; + +import {buildCommonSchema} from '@/registry/validation'; + +const buildTime24hSchema = (intl: IntlShape) => + 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; diff --git a/src/utils/inputmask.ts b/src/utils/inputmask.ts new file mode 100644 index 00000000..938eb16e --- /dev/null +++ b/src/utils/inputmask.ts @@ -0,0 +1,23 @@ +/** + * This is Formio.js 4.13.x's input mask behaviour. + * + * It is known to have accessibility issues because the value is set, causing some + * screenreaders to read out the placeholders. For more information, see + * https://giovanicamara.com/blog/accessible-input-masking/ + * + * This functionality only exists here to achieve feature parity with the native + * form builder, for the actual SDK/formio renderer, a better solution needs to be + * found. + * + * @deprecated Do not use this (anymore) for user-facing forms. + * + */ +// @ts-ignore there are no type definitions +import {conformToMask} from '@formio/vanilla-text-mask'; +import {Utils} from 'formiojs'; + +export const applyInputMask = (textValue: string, mask: string) => { + const options = {placeholderChar: '_'}; + const result = conformToMask(textValue, Utils.getInputMask(mask), options); + return result.conformedValue; +};