From 88b74de4c9b0043b41e01190597bf9a9b6b8c511 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 22 Nov 2023 16:52:41 +0100 Subject: [PATCH 1/3] :sparkles: [#62] Implement edit form for BSN type component --- src/registry/bsn/edit-validation.ts | 7 + src/registry/bsn/edit.tsx | 194 ++++++++++++++++++++++++++++ src/registry/bsn/index.ts | 10 ++ src/registry/bsn/preview.tsx | 40 ++++++ src/registry/index.tsx | 2 + 5 files changed, 253 insertions(+) create mode 100644 src/registry/bsn/edit-validation.ts create mode 100644 src/registry/bsn/edit.tsx create mode 100644 src/registry/bsn/index.ts create mode 100644 src/registry/bsn/preview.tsx diff --git a/src/registry/bsn/edit-validation.ts b/src/registry/bsn/edit-validation.ts new file mode 100644 index 00000000..1cb4c6eb --- /dev/null +++ b/src/registry/bsn/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/bsn/edit.tsx b/src/registry/bsn/edit.tsx new file mode 100644 index 00000000..60942239 --- /dev/null +++ b/src/registry/bsn/edit.tsx @@ -0,0 +1,194 @@ +import {BsnComponentSchema} 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'; + +/** + * Form to configure a Formio 'bsn' 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']); + 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: '999999999', + // basic tab + label: '', + key: '', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + multiple: false, + hidden: false, + clearOnHide: true, + isSensitiveData: true, + defaultValue: '', + disabled: false, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + 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="999999999" + /> + ); +}; + +export default EditForm; diff --git a/src/registry/bsn/index.ts b/src/registry/bsn/index.ts new file mode 100644 index 00000000..f636dcf3 --- /dev/null +++ b/src/registry/bsn/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/bsn/preview.tsx b/src/registry/bsn/preview.tsx new file mode 100644 index 00000000..3e1dd959 --- /dev/null +++ b/src/registry/bsn/preview.tsx @@ -0,0 +1,40 @@ +import {BsnComponentSchema} from '@open-formulieren/types'; + +import {TextField} from '@/components/formio'; + +import {ComponentPreviewProps} from '../types'; + +/** + * Show a formio bsn 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, + tooltip, + validate = {}, + disabled = false, + multiple, + inputMask, + } = component; + const {required = false} = validate; + return ( + + ); +}; + +export default Preview; diff --git a/src/registry/index.tsx b/src/registry/index.tsx index c3e26cbc..23357d61 100644 --- a/src/registry/index.tsx +++ b/src/registry/index.tsx @@ -1,5 +1,6 @@ import {AnyComponentSchema, FallbackSchema, hasOwnProperty} from '@/types'; +import BSN from './bsn'; import Currency from './currency'; import DateField from './date'; import DateTimeField from './datetime'; @@ -49,6 +50,7 @@ const REGISTRY: Registry = { // Special types: iban: Iban, licenseplate: Licenseplate, + bsn: BSN, }; export {Fallback}; From 554eac4f066ef94b09f7c6d8258b718c50de712e Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 22 Nov 2023 16:57:54 +0100 Subject: [PATCH 2/3] :white_check_mark: [#62] Add stories for BSN preview component --- src/components/ComponentPreview.stories.tsx | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index f4289573..f22459cd 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -753,3 +753,77 @@ export const RadioVariable: Story = { }, }, }; + +export const BSN: Story = { + name: 'BSN', + render: Template, + + args: { + component: { + type: 'bsn', + id: 'bsn', + key: 'bsnPreview', + label: 'BSN preview', + description: 'A preview of the BSN Formio component', + hidden: true, // must be ignored + inputMask: '999999999', + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('BSN preview'); + await canvas.findByText('A preview of the BSN Formio component'); + + // check that the input name is set correctly + const input = canvas.getByLabelText('BSN preview'); + // @ts-ignore + await expect(input.getAttribute('name')).toBe(args.component.key); + + expect(input).toHaveAttribute('placeholder', '_________'); + await userEvent.type(input, '123456789'); + expect(input).toHaveDisplayValue('123456789'); + }, +}; + +export const BSNMultiple: Story = { + name: 'BSN Multiple', + render: Template, + + args: { + component: { + type: 'bsn', + id: 'bsn', + key: 'bsnPreview', + label: 'BSN 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-bsnPreview[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-bsnPreview[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-bsnPreview[0]')).toHaveDisplayValue(''); + await expect(canvas.queryByTestId('input-bsnPreview[1]')).not.toBeInTheDocument(); + }, +}; From bf97dc327bbb6a0883749b1f6490e2ddc55a0b12 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 23 Nov 2023 09:02:22 +0100 Subject: [PATCH 3/3] :white_check_mark: [#62] Add story for BSN component configuration --- .../ComponentConfiguration.stories.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index ab1b7b71..9180d5a3 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -1386,3 +1386,85 @@ export const Radio: Story = { }); }, }; + +export const BSN: Story = { + render: Template, + name: 'type: bsn', + + args: { + component: { + id: 'wekruya', + inputMask: '999999999', + validateOn: 'blur', + type: 'bsn', + key: 'bsn', + label: 'A BSN field', + validate: { + required: false, + }, + }, + + builderInfo: { + title: 'BSN Field', + icon: 'id-card-o', + group: 'basic', + weight: 10, + schema: {}, + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A BSN field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aBsnField'); + }); + 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. We use fireEvent because firefox borks on userEvent.click, see: + // https://github.com/testing-library/user-event/issues/1149 + fireEvent.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(); + }, +};