diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 6b122b4a..3761016a 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -4,6 +4,11 @@ "description": "Component property 'Property Name' label", "originalDefault": "Property Name" }, + "+9x9sg": { + "defaultMessage": "From variable", + "description": "Data source option label for value 'variable'", + "originalDefault": "From variable" + }, "+XwAuT": { "defaultMessage": "Remove", "description": "Remove component button", @@ -154,6 +159,11 @@ "description": "Character count", "originalDefault": "{length} {length, plural, one {character} other {characters}}" }, + "5xNPpS": { + "defaultMessage": "Option label", + "description": "Accessible label for option label", + "originalDefault": "Option label" + }, "5y8Yt4": { "defaultMessage": "Save the attachment in the Documents API with this InformatieObjectType. If unspecified, the registration plugin defaults are used.", "description": "Tooltip for 'registration.informatieobjecttype' builder field", @@ -234,11 +244,21 @@ "description": "Placeholder for 'validate.min' builder field", "originalDefault": "Minimum value" }, + "BKe/DT": { + "defaultMessage": "The option value is a required field.", + "description": "Form builder option value required error", + "originalDefault": "The option value is a required field." + }, "BvRBef": { "defaultMessage": "Decimal places", "description": "Label for 'decimalLimit' builder field", "originalDefault": "Decimal places" }, + "C48xJT": { + "defaultMessage": "Items expression", + "description": "Label for 'openForms.itemsExpression' builder field", + "originalDefault": "Items expression" + }, "C64ptd": { "defaultMessage": "Maximum length", "description": "Placeholder for 'validate.maxLength' builder field", @@ -264,6 +284,11 @@ "description": "Tooltip validation error translations panel", "originalDefault": "Custom error messages for this component and their translations" }, + "DJWATl": { + "defaultMessage": "The values that can be picked for this field. Values are the text that is submitted with the form data. Labels are the text next to radio buttons, checkboxes and options in dropdowns.", + "description": "Tooltip for 'values' builder field", + "originalDefault": "The values that can be picked for this field. Values are the text that is submitted with the form data. Labels are the text next to radio buttons, checkboxes and options in dropdowns." + }, "Dm3S1P": { "defaultMessage": "Placeholder", "description": "Component property 'Placeholder' label", @@ -309,6 +334,11 @@ "description": "Save component configuration button", "originalDefault": "Save" }, + "FOlQaP": { + "defaultMessage": "Label", + "description": "Option label table header/label", + "originalDefault": "Label" + }, "Fc/emc": { "defaultMessage": "Add another", "description": "'Add another' button text for 'multiple' components", @@ -329,6 +359,11 @@ "description": "Label for 'ReadOnly' builder field", "originalDefault": "Read only" }, + "IOko1U": { + "defaultMessage": "Remove", + "description": "Values table: accessible label to remove an option", + "originalDefault": "Remove" + }, "JDYF2q": { "defaultMessage": "The data entered in this component will be removed in accordance with the privacy settings.", "description": "Tooltip for 'IsSensitiveData' builder field", @@ -349,6 +384,11 @@ "description": "Label for 'Hidden' builder field", "originalDefault": "Hidden" }, + "Kj8P8I": { + "defaultMessage": "Option value", + "description": "Accessible label for option value", + "originalDefault": "Option value" + }, "KrJ+rN": { "defaultMessage": "The maximum number of files that can be uploaded.", "description": "Tooltip for 'maxNumberOfFiles' builder field", @@ -399,6 +439,11 @@ "description": "Label identifier role main", "originalDefault": "Main" }, + "RuAQwk": { + "defaultMessage": "Add/remove", + "description": "Option add/remove table header/label", + "originalDefault": "Add/remove" + }, "RxmRzd": { "defaultMessage": "When this is checked, the filetypes configured in the global settings will be used.", "description": "Tooltip for 'useConfigFiletypes' builder field", @@ -439,6 +484,16 @@ "description": "Tooltip for 'validate.plugins' builder field", "originalDefault": "Select the plugin(s) to use for the validation functionality." }, + "UlVtQd": { + "defaultMessage": "Move up", + "description": "Options table: move option up", + "originalDefault": "Move up" + }, + "UmLruQ": { + "defaultMessage": "Data source", + "description": "Label for 'openForms.dataSrc' builder field", + "originalDefault": "Data source" + }, "Un5b5T": { "defaultMessage": "Minimum time", "description": "Label for 'validate.minTime' builder field", @@ -534,6 +589,11 @@ "description": "Label for 'maxNumberOfFiles' builder field", "originalDefault": "Maximum number of files" }, + "arAMAF": { + "defaultMessage": "The option label is a required field.", + "description": "Form builder option label required error", + "originalDefault": "The option label is a required field." + }, "asZV7t": { "defaultMessage": "Derive street name", "description": "Label for 'deriveStreetName' builder field", @@ -649,6 +709,11 @@ "description": "Label for 'registration.informatieobjecttype' builder field", "originalDefault": "Information object type" }, + "fe3wyn": { + "defaultMessage": "A JSON logic expression returning a variable (of array type) whose items should be used as the options for this component.", + "description": "Description for the 'openForms.itemsExpression' builder field", + "originalDefault": "A JSON logic expression returning a variable (of array type) whose items should be used as the options for this component." + }, "h0B9Fr": { "defaultMessage": "The property name must only contain alphanumeric characters, underscores, dots and dashes and should not be ended by dash or dot.", "description": "Form builder 'key' pattern validation error", @@ -679,16 +744,31 @@ "description": "Label for 'ClearOnHide' builder field", "originalDefault": "Clear on hide" }, + "j2vQH3": { + "defaultMessage": "Values", + "description": "Label for the 'values' builder field", + "originalDefault": "Values" + }, "jLg5l6": { "defaultMessage": "House number component", "description": "Label for 'deriveHouseNumber' builder field", "originalDefault": "House number component" }, + "k1+ljn": { + "defaultMessage": "Move down", + "description": "Options table: move option down", + "originalDefault": "Move down" + }, "k9tAyW": { "defaultMessage": "Error message", "description": "Label for translation message for validation error code", "originalDefault": "Error message" }, + "kg/eh1": { + "defaultMessage": "Choice/option translations", + "description": "Values/options translations table header", + "originalDefault": "Choice/option translations" + }, "kkTfpu": { "defaultMessage": "Description", "description": "Component property 'Description' label", @@ -749,11 +829,21 @@ "description": "Component translations 'suffix' property label", "originalDefault": "Suffix (e.g. m²)" }, + "p7g2h+": { + "defaultMessage": "Add another", + "description": "Add another option button label", + "originalDefault": "Add another" + }, "pDJBaK": { "defaultMessage": "Minimum value", "description": "Label for 'validate.min' builder field", "originalDefault": "Minimum value" }, + "paY2Oa": { + "defaultMessage": "Options from expression: {expression}", + "description": "Selectboxes dummy option for itemsExpression", + "originalDefault": "Options from expression: {expression}" + }, "pq7q6N": { "defaultMessage": "Show in PDF", "description": "Label for 'showInPDF' builder field", @@ -764,6 +854,11 @@ "description": "Component edit form tab title for 'Registration' tab", "originalDefault": "Registration" }, + "sU/n0S": { + "defaultMessage": "Manually fill in", + "description": "Data source option label for value 'manual'", + "originalDefault": "Manually fill in" + }, "sgmcmf": { "defaultMessage": "Plugin attribute", "description": "Label for 'prefill.attribute' builder field", @@ -804,6 +899,16 @@ "description": "Tooltip for 'prefill.attribute' builder field", "originalDefault": "Specify the attribute holding the pre-fill data." }, + "wOq8Pb": { + "defaultMessage": "Translation for option with value \"{value}\"", + "description": "Accessible label for option label translation field", + "originalDefault": "Translation for option with value \"{value}\"" + }, + "wRNK2B": { + "defaultMessage": "Value", + "description": "Option value table header/label", + "originalDefault": "Value" + }, "wZ99FU": { "defaultMessage": "The regular expression pattern test that the field value must pass before the form can be submitted.", "description": "Tooltip for 'validate.pattern' builder field", @@ -839,6 +944,11 @@ "description": "Tooltip for 'Hidden' builder field", "originalDefault": "Hide a field from the form." }, + "ySkT14": { + "defaultMessage": "How to specify the available options.", + "description": "Tooltip for 'openForms.dataSrc' builder field", + "originalDefault": "How to specify the available options." + }, "yujuQr": { "defaultMessage": "Maximum height", "description": "Label for 'of.image.resize.height' builder field", diff --git a/i18n/messages/nl.json b/i18n/messages/nl.json index a374af94..2869be3e 100644 --- a/i18n/messages/nl.json +++ b/i18n/messages/nl.json @@ -4,6 +4,11 @@ "description": "Component property 'Property Name' label", "originalDefault": "Property Name" }, + "+9x9sg": { + "defaultMessage": "Gebruik variabele", + "description": "Data source option label for value 'variable'", + "originalDefault": "From variable" + }, "+XwAuT": { "defaultMessage": "Verwijderen", "description": "Remove component button", @@ -65,7 +70,7 @@ "originalDefault": "Regular Expression Pattern" }, "1pC7IP": { - "defaultMessage": "Specifieer een template voor de naam van het/de ge\u00fcploade bestand(en). '{{ fileName }}' bevat de oorspronkelijke bestandsnaam.", + "defaultMessage": "Specifieer een template voor de naam van het/de geüploade bestand(en). '{{ fileName }}' bevat de oorspronkelijke bestandsnaam.", "description": "Tooltip for 'file.name' builder field", "originalDefault": "Specify template for name of uploaded file(s). '{{ fileName }}' contains the original filename." }, @@ -154,6 +159,11 @@ "description": "Character count", "originalDefault": "{length} {length, plural, one {character} other {characters}}" }, + "5xNPpS": { + "defaultMessage": "Optielabel", + "description": "Accessible label for option label", + "originalDefault": "Option label" + }, "5y8Yt4": { "defaultMessage": "Sla het bestand op in de Documenten API met dit documenttype. Indien leeg, dan worden algemene instellingen gebruikt.", "description": "Tooltip for 'registration.informatieobjecttype' builder field", @@ -234,11 +244,21 @@ "description": "Placeholder for 'validate.min' builder field", "originalDefault": "Minimum value" }, + "BKe/DT": { + "defaultMessage": "De optiewaarde is een verplicht veld.", + "description": "Form builder option value required error", + "originalDefault": "The option value is a required field." + }, "BvRBef": { "defaultMessage": "Decimalen", "description": "Label for 'decimalLimit' builder field", "originalDefault": "Decimal places" }, + "C48xJT": { + "defaultMessage": "Opties-expressie", + "description": "Label for 'openForms.itemsExpression' builder field", + "originalDefault": "Items expression" + }, "C64ptd": { "defaultMessage": "Maximale lengte", "description": "Placeholder for 'validate.maxLength' builder field", @@ -265,6 +285,11 @@ "description": "Tooltip validation error translations panel", "originalDefault": "Custom error messages for this component and their translations" }, + "DJWATl": { + "defaultMessage": "De mogelijke keuzeopties voor dit veld. De waarden worden in de formuliergegevens opgeslagen. De labels zijn de teksten die aan de gebruiker getoond worden.", + "description": "Tooltip for 'values' builder field", + "originalDefault": "The values that can be picked for this field. Values are the text that is submitted with the form data. Labels are the text next to radio buttons, checkboxes and options in dropdowns." + }, "Dm3S1P": { "defaultMessage": "Placeholder", "description": "Component property 'Placeholder' label", @@ -311,6 +336,12 @@ "description": "Save component configuration button", "originalDefault": "Save" }, + "FOlQaP": { + "defaultMessage": "Label", + "description": "Option label table header/label", + "isTranslated": true, + "originalDefault": "Label" + }, "Fc/emc": { "defaultMessage": "Nog één toevoegen", "description": "'Add another' button text for 'multiple' components", @@ -331,6 +362,11 @@ "description": "Label for 'ReadOnly' builder field", "originalDefault": "Read only" }, + "IOko1U": { + "defaultMessage": "Verwijderen", + "description": "Values table: accessible label to remove an option", + "originalDefault": "Remove" + }, "JDYF2q": { "defaultMessage": "Gegevens opgevoerd in dit component worden geschoond volgens de privacy-instellingen.", "description": "Tooltip for 'IsSensitiveData' builder field", @@ -352,6 +388,11 @@ "description": "Label for 'Hidden' builder field", "originalDefault": "Hidden" }, + "Kj8P8I": { + "defaultMessage": "Optiewaarde", + "description": "Accessible label for option value", + "originalDefault": "Option value" + }, "KrJ+rN": { "defaultMessage": "Het maximaal aantal bestanden die mogen geüpload worden.", "description": "Tooltip for 'maxNumberOfFiles' builder field", @@ -403,6 +444,11 @@ "description": "Label identifier role main", "originalDefault": "Main" }, + "RuAQwk": { + "defaultMessage": "Verwijderen", + "description": "Option add/remove table header/label", + "originalDefault": "Add/remove" + }, "RxmRzd": { "defaultMessage": "Indien ingeschakeld, gebruik dan de algemene instellingen voor toegestane bestandstypen.", "description": "Tooltip for 'useConfigFiletypes' builder field", @@ -444,6 +490,16 @@ "description": "Tooltip for 'validate.plugins' builder field", "originalDefault": "Select the plugin(s) to use for the validation functionality." }, + "UlVtQd": { + "defaultMessage": "Omhoog", + "description": "Options table: move option up", + "originalDefault": "Move up" + }, + "UmLruQ": { + "defaultMessage": "Keuzeopties", + "description": "Label for 'openForms.dataSrc' builder field", + "originalDefault": "Data source" + }, "Un5b5T": { "defaultMessage": "Minimale tijd", "description": "Label for 'validate.minTime' builder field", @@ -539,6 +595,11 @@ "description": "Label for 'maxNumberOfFiles' builder field", "originalDefault": "Maximum number of files" }, + "arAMAF": { + "defaultMessage": "Het optielabel is een verplicht veld.", + "description": "Form builder option label required error", + "originalDefault": "The option label is a required field." + }, "asZV7t": { "defaultMessage": "Straatnaam afleiden", "description": "Label for 'deriveStreetName' builder field", @@ -656,6 +717,11 @@ "description": "Label for 'registration.informatieobjecttype' builder field", "originalDefault": "Information object type" }, + "fe3wyn": { + "defaultMessage": "Geef een JsonLogic expressie voor de lijst van mogelijke opties. Evaluatie van de expressie moet een lijst teruggeven, waarbij elk element een lijst (array) is van [waarde, label] combinaties.", + "description": "Description for the 'openForms.itemsExpression' builder field", + "originalDefault": "A JSON logic expression returning a variable (of array type) whose items should be used as the options for this component." + }, "h0B9Fr": { "defaultMessage": "De eigenschapsnaam mag alleen alfanumerieke tekens, onderstrepingstekens, punten en streepjes bevatten en mag niet worden afgesloten met een streepje of punt.", "description": "Form builder 'key' pattern validation error", @@ -686,16 +752,31 @@ "description": "Label for 'ClearOnHide' builder field", "originalDefault": "Clear on hide" }, + "j2vQH3": { + "defaultMessage": "Waarden", + "description": "Label for the 'values' builder field", + "originalDefault": "Values" + }, "jLg5l6": { "defaultMessage": "Huisnummercomponent", "description": "Label for 'deriveHouseNumber' builder field", "originalDefault": "House number component" }, + "k1+ljn": { + "defaultMessage": "Omlaag", + "description": "Options table: move option down", + "originalDefault": "Move down" + }, "k9tAyW": { "defaultMessage": "Foutmelding", "description": "Label for translation message for validation error code", "originalDefault": "Error message" }, + "kg/eh1": { + "defaultMessage": "Waardenvertalingen", + "description": "Values/options translations table header", + "originalDefault": "Choice/option translations" + }, "kkTfpu": { "defaultMessage": "Beschrijving", "description": "Component property 'Description' label", @@ -758,11 +839,21 @@ "description": "Component translations 'suffix' property label", "originalDefault": "Suffix (e.g. m²)" }, + "p7g2h+": { + "defaultMessage": "Nog één toevoegen", + "description": "Add another option button label", + "originalDefault": "Add another" + }, "pDJBaK": { "defaultMessage": "Minimale waarde", "description": "Label for 'validate.min' builder field", "originalDefault": "Minimum value" }, + "paY2Oa": { + "defaultMessage": "Waarden via de expressie: {expression}", + "description": "Selectboxes dummy option for itemsExpression", + "originalDefault": "Options from expression: {expression}" + }, "pq7q6N": { "defaultMessage": "Weergeven in PDF", "description": "Label for 'showInPDF' builder field", @@ -773,6 +864,11 @@ "description": "Component edit form tab title for 'Registration' tab", "originalDefault": "Registration" }, + "sU/n0S": { + "defaultMessage": "Handmatig opvoeren", + "description": "Data source option label for value 'manual'", + "originalDefault": "Manually fill in" + }, "sgmcmf": { "defaultMessage": "Pluginattribuut", "description": "Label for 'prefill.attribute' builder field", @@ -813,6 +909,16 @@ "description": "Tooltip for 'prefill.attribute' builder field", "originalDefault": "Specify the attribute holding the pre-fill data." }, + "wOq8Pb": { + "defaultMessage": "Vertaling voor het label van de optie met waarde \"{value}\"", + "description": "Accessible label for option label translation field", + "originalDefault": "Translation for option with value \"{value}\"" + }, + "wRNK2B": { + "defaultMessage": "Waarde", + "description": "Option value table header/label", + "originalDefault": "Value" + }, "wZ99FU": { "defaultMessage": "Het patroon van reguliere expressie waar de waarde aan moet voldoen voor het formulier kan verstuurd worden.", "description": "Tooltip for 'validate.pattern' builder field", @@ -848,6 +954,11 @@ "description": "Tooltip for 'Hidden' builder field", "originalDefault": "Hide a field from the form." }, + "ySkT14": { + "defaultMessage": "Selecteer een databron voor de keuzeopties.", + "description": "Tooltip for 'openForms.dataSrc' builder field", + "originalDefault": "How to specify the available options." + }, "yujuQr": { "defaultMessage": "Maximale hoogte", "description": "Label for 'of.image.resize.height' builder field", diff --git a/package-lock.json b/package-lock.json index fcb13767..ce364a33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14444,9 +14444,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.4.3", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", - "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", + "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", "dev": true, "engines": { "node": ">=12", @@ -41192,9 +41192,9 @@ } }, "@testing-library/user-event": { - "version": "14.4.3", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", - "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", + "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", "dev": true }, "@trivago/prettier-plugin-sort-imports": { diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 62f0531b..1ffe3f96 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -777,6 +777,10 @@ export const FileUpload: Story = { component: { id: 'kiweljhr', storage: 'url', + webcam: false, + options: { + withCredentials: true, + }, url: '', type: 'file', key: 'file', @@ -900,3 +904,208 @@ export const FileUpload: Story = { }); }, }; + +export const SelectBoxes: Story = { + render: Template, + name: 'type: selectboxes', + + args: { + component: { + id: 'wqimsadk', + type: 'selectboxes', + key: 'selectboxes', + label: 'A selectboxes field', + openForms: { + dataSrc: 'manual', + translations: {}, + }, + values: [], + defaultValue: {}, + }, + + builderInfo: { + title: 'Select Boxes', + icon: 'plus-square', + group: 'basic', + weight: 60, + schema: {}, + }, + }, + + play: async ({canvasElement, step, args}) => { + const canvas = within(canvasElement); + const editForm = within(canvas.getByTestId('componentEditForm')); + const preview = within(canvas.getByTestId('componentPreview')); + + await expect(canvas.getByLabelText('Label')).toHaveValue('A selectboxes field'); + await waitFor(async () => { + await expect(canvas.getByLabelText('Property Name')).toHaveValue('aSelectboxesField'); + }); + await expect(canvas.getByLabelText('Description')).toHaveValue(''); + await expect(canvas.getByLabelText('Tooltip')).toHaveValue(''); + await expect(canvas.getByLabelText('Show in summary')).toBeChecked(); + await expect(canvas.getByLabelText('Show in email')).not.toBeChecked(); + await expect(canvas.getByLabelText('Show in PDF')).toBeChecked(); + + // ensure that changing fields in the edit form properly update the preview + + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Updated preview label'); + expect(await preview.findByText('Updated preview label')); + + const previewInput = preview.getByRole('checkbox'); + await expect(previewInput).not.toBeChecked(); + + // Ensure that the manually entered key is kept instead of derived from the label, + // even when key/label components are not mounted. + const keyInput = canvas.getByLabelText('Property Name'); + // fireEvent is deliberate, as userEvent.clear + userEvent.type briefly makes the field + // not have any value, which triggers the generate-key-from-label behaviour. + fireEvent.change(keyInput, {target: {value: 'customKey'}}); + await userEvent.click(canvas.getByRole('tab', {name: 'Basic'})); + await userEvent.clear(canvas.getByLabelText('Label')); + await userEvent.type(canvas.getByLabelText('Label'), 'Other label', {delay: 50}); + await expect(canvas.getByLabelText('Property Name')).toHaveDisplayValue('customKey'); + + await step('Set up manual options', async () => { + // enter some possible options + const firstOptionLabelInput = canvas.getByLabelText('Option label'); + expect(firstOptionLabelInput).toHaveDisplayValue(''); + await userEvent.type(firstOptionLabelInput, 'Option label 1'); + const firstOptionValue = canvas.getByLabelText('Option value'); + await waitFor(() => expect(firstOptionValue).toHaveDisplayValue('optionLabel1')); + + // add a second option + await userEvent.click(canvas.getByRole('button', {name: 'Add another'})); + const optionLabels = canvas.queryAllByLabelText('Option label'); + const optionValues = canvas.queryAllByLabelText('Option value'); + expect(optionLabels).toHaveLength(2); + expect(optionValues).toHaveLength(2); + await userEvent.type(optionValues[1], 'manualValue'); + await userEvent.type(optionLabels[1], 'Second option'); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalledWith({ + id: 'wqimsadk', + type: 'selectboxes', + // basic tab + label: 'Other label', + key: 'customKey', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + openForms: { + dataSrc: 'manual', + translations: {}, + }, + values: [ + { + value: 'optionLabel1', + label: 'Option label 1', + }, + { + value: 'manualValue', + label: 'Second option', + openForms: {translations: {}}, + }, + ], + defaultValue: { + optionLabel1: false, + manualValue: false, + }, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + translatedErrors: { + nl: {required: ''}, + }, + // registration tab + registration: { + attribute: '', + }, + }); + // @ts-expect-error + args.onSubmit.mockClear(); + }); + + await step('Option labels are translatable', async () => { + await userEvent.click(canvas.getByRole('tab', {name: 'Translations'})); + + // check that the option labels are in the translations table + expect(await editForm.findByText('Option label 1')).toBeVisible(); + expect(await editForm.findByText('Second option')).toBeVisible(); + }); + + await step('Set up itemsExpression for options', async () => { + await userEvent.click(canvas.getByRole('tab', {name: 'Basic'})); + + canvas.getByLabelText('Data source').focus(); + await userEvent.keyboard('[ArrowDown]'); + await userEvent.click(await canvas.findByText('From variable')); + const itemsExpressionInput = canvas.getByLabelText('Items expression'); + await userEvent.clear(itemsExpressionInput); + // { needs to be escaped: https://github.com/testing-library/user-event/issues/584 + const expression = '{"var": "someVar"}'.replace(/[{[]/g, '$&$&'); + await userEvent.type(itemsExpressionInput, expression); + + await expect(editForm.queryByLabelText('Default value')).toBeNull(); + await expect(preview.getByRole('checkbox', {name: /Options from expression:/})).toBeVisible(); + + await userEvent.click(canvas.getByRole('button', {name: 'Save'})); + expect(args.onSubmit).toHaveBeenCalledWith({ + id: 'wqimsadk', + type: 'selectboxes', + // basic tab + label: 'Other label', + key: 'customKey', + description: '', + tooltip: '', + showInSummary: true, + showInEmail: false, + showInPDF: true, + hidden: false, + clearOnHide: true, + isSensitiveData: false, + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'someVar'}, + translations: {}, + }, + defaultValue: {}, + // Advanced tab + conditional: { + show: undefined, + when: '', + eq: '', + }, + // Validation tab + validate: { + required: false, + plugins: [], + }, + translatedErrors: { + nl: {required: ''}, + }, + // registration tab + registration: { + attribute: '', + }, + }); + // @ts-expect-error + args.onSubmit.mockClear(); + }); + }, +}; diff --git a/src/components/ComponentPreview.stories.tsx b/src/components/ComponentPreview.stories.tsx index c197afc9..73728894 100644 --- a/src/components/ComponentPreview.stories.tsx +++ b/src/components/ComponentPreview.stories.tsx @@ -1,6 +1,6 @@ import {expect} from '@storybook/jest'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; -import {userEvent, within} from '@storybook/testing-library'; +import {fireEvent, userEvent, within} from '@storybook/testing-library'; import ComponentPreview from './ComponentPreview'; @@ -615,3 +615,72 @@ export const File: Story = { await canvas.findByText('A preview of the file Formio component'); }, }; + +export const SelectBoxes: Story = { + name: 'Selectboxes manual values', + render: Template, + + args: { + component: { + type: 'selectboxes', + id: 'selectboxes', + key: 'selectboxesPreview', + label: 'Selectboxes preview', + description: 'A preview of the selectboxes Formio component', + openForms: { + dataSrc: 'manual', + translations: {}, + }, + values: [ + { + value: 'option1', + label: 'Option 1', + }, + { + value: 'option2', + label: 'Option 2', + }, + ], + }, + }, + + play: async ({canvasElement, args}) => { + const canvas = within(canvasElement); + + // check that the user-controlled content is visible + await canvas.findByText('Selectboxes preview'); + await canvas.findByText('A preview of the selectboxes Formio component'); + + // check that the input name is set correctly + const firstOptionInput = canvas.getByLabelText('Option 1'); + // @ts-ignore + await expect(firstOptionInput.getAttribute('name').startsWith(args.component.key)).toBe(true); + + // check the toggle state of a checkbox + await expect(firstOptionInput).not.toBeChecked(); + // https://github.com/testing-library/user-event/issues/1149 applies to radio and + // checkbox inputs + fireEvent.click(canvas.getByText('Option 1')); + await expect(firstOptionInput).toBeChecked(); + }, +}; + +export const SelectBoxesVariable: Story = { + name: 'Selectboxes variable for values', + render: Template, + + args: { + component: { + type: 'selectboxes', + id: 'selectboxes', + key: 'selectboxesPreview', + label: 'Selectboxes preview', + description: 'A preview of the selectboxes Formio component', + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'foo'}, + translations: {}, + }, + }, + }, +}; diff --git a/src/components/JSONEdit.tsx b/src/components/JSONEdit.tsx index c47603a5..633b6fdf 100644 --- a/src/components/JSONEdit.tsx +++ b/src/components/JSONEdit.tsx @@ -1,3 +1,4 @@ +import {JSONObject} from '@open-formulieren/types/lib/types'; import clsx from 'clsx'; import {useFormikContext} from 'formik'; import {TextareaHTMLAttributes, useRef, useState} from 'react'; @@ -5,11 +6,13 @@ import {TextareaHTMLAttributes, useRef, useState} from 'react'; interface JSONEditProps { data: unknown; // JSON.stringify first argument has the 'any' type in TS itself... className?: string; + name?: string; } const JSONEdit: React.FC> = ({ data, className = 'form-control', + name = '', ...props }) => { const dataAsJSON = JSON.stringify(data, null, 2); @@ -17,7 +20,11 @@ const JSONEdit: React.FC setFieldValue(name, v) : setValues; // synchronize external state changes const isFocused = inputRef.current == document.activeElement; @@ -37,7 +44,8 @@ const JSONEdit: React.FC diff --git a/src/components/builder/values/i18n.stories.tsx b/src/components/builder/values/i18n.stories.tsx index 75e647f4..3d597826 100644 --- a/src/components/builder/values/i18n.stories.tsx +++ b/src/components/builder/values/i18n.stories.tsx @@ -13,7 +13,7 @@ const ValuesTranslationsComponent = ValuesTranslations<{ }>; export default { - title: 'Formio/Builder/ValuesTable/Translations', + title: 'Formio/Builder/Values/Translations', component: ValuesTranslationsComponent, render: args => ( diff --git a/src/components/builder/values/i18n.tsx b/src/components/builder/values/i18n.tsx index 7fc17f98..acdb7d0a 100644 --- a/src/components/builder/values/i18n.tsx +++ b/src/components/builder/values/i18n.tsx @@ -22,7 +22,7 @@ export interface ValuesTranslationsProps { * `ComponentTranslations` so that all translations are managed in a single * tab. */ -function ValuesTranslations({name}: ValuesTranslationsProps) { +export function ValuesTranslations({name}: ValuesTranslationsProps) { const intl = useIntl(); const {activeLanguage} = useContext(ComponentTranslationsContext); const {getFieldProps} = useFormikContext(); diff --git a/src/components/builder/values/index.ts b/src/components/builder/values/index.ts index b4b52bf1..78e5f72d 100644 --- a/src/components/builder/values/index.ts +++ b/src/components/builder/values/index.ts @@ -5,4 +5,6 @@ * - selectboxes * - radio */ -export {default as ValuesTable} from './ValuesTable'; +export * from './values-config'; +export {default as ValuesTable} from './values-table'; +export {default as ValuesTranslations} from './i18n'; diff --git a/src/components/builder/values/items-expression.stories.ts b/src/components/builder/values/items-expression.stories.ts new file mode 100644 index 00000000..805bcb59 --- /dev/null +++ b/src/components/builder/values/items-expression.stories.ts @@ -0,0 +1,42 @@ +import {expect} from '@storybook/jest'; +import {Meta, StoryObj} from '@storybook/react'; +import {within} from '@storybook/testing-library'; + +import {withFormik} from '@/sb-decorators'; + +import ItemsExpression from './items-expression'; + +export default { + title: 'Formio/Builder/Values/ItemsExpression', + component: ItemsExpression, + decorators: [withFormik], + parameters: { + controls: {hideNoControlsWarning: true}, + modal: {noModal: true}, + formik: { + initialValues: { + openForms: { + itemsExpression: {var: 'someVar'}, + }, + }, + }, + }, + args: { + name: 'values', + }, + argTypes: { + name: {control: {disable: true}}, + }, + tags: ['autodocs'], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const stringified = JSON.stringify({var: 'someVar'}, null, 2); + expect(canvas.getByRole('textbox')).toHaveDisplayValue(stringified); + }, +}; diff --git a/src/components/builder/values/items-expression.tsx b/src/components/builder/values/items-expression.tsx new file mode 100644 index 00000000..bfeb4e4c --- /dev/null +++ b/src/components/builder/values/items-expression.tsx @@ -0,0 +1,51 @@ +import {JSONObject} from '@open-formulieren/types/lib/types'; +import {useFormikContext} from 'formik'; +import {FormattedMessage} from 'react-intl'; + +import JSONEdit from '@/components/JSONEdit'; +import {Component, Description} from '@/components/formio'; + +const NAME = 'openForms.itemsExpression'; + +/** + * The `ItemsExpression` component is used to specify the JsonLogic expression to + * calculate the values/options for a component. + * + * @todo: this would really benefit from a nice, context-aware JsonLogic editor. + */ +export const ItemsExpression: React.FC = () => { + const {getFieldProps} = useFormikContext(); + const {value = ''} = getFieldProps(NAME); + + const htmlId = `editform-${NAME}`; + return ( + + } + > +
+ +
+ + + } + /> +
+ ); +}; + +export default ItemsExpression; diff --git a/src/components/builder/values/OptionRow.tsx b/src/components/builder/values/option-row.tsx similarity index 96% rename from src/components/builder/values/OptionRow.tsx rename to src/components/builder/values/option-row.tsx index f490ac6d..191951de 100644 --- a/src/components/builder/values/OptionRow.tsx +++ b/src/components/builder/values/option-row.tsx @@ -115,6 +115,8 @@ const OptionRow: React.FC = ({name, index, arrayHelpers}) => { + {isSubmitting && 'Submitting...'} + + )} + + ); + }, + args: { + onSubmit: jest.fn(), + }, + play: async ({canvasElement, step, args}) => { + const canvas = within(canvasElement); + + const doSubmit = async () => { + const btn = canvas.getByRole('button', {name: 'Submit'}); + // https://github.com/testing-library/user-event/issues/1075 + // apparently userEvent.click(submitButton) doesn't do anything in Firefox :/ + fireEvent.click(btn); + await waitFor(async () => { + await expect(canvas.queryByText('Submitting...')).toBeNull(); + }); + }; + + await step('Nothing selected', async () => { + await doSubmit(); + + await expect(args.onSubmit).toHaveBeenCalledWith({openForms: {dataSrc: ''}}); + // @ts-expect-error jest mocks + TS doesn't play nice together + args.onSubmit.mockClear(); + }); + + await step('Manual values', async () => { + // Open the dropdown + const dataSrcSelect = canvas.getByLabelText('Data source'); + await userEvent.click(dataSrcSelect); + await userEvent.keyboard('[ArrowDown]'); + + await userEvent.click(await canvas.findByText('Manually fill in')); + const addBtn = await canvas.findByRole('button', {name: 'Add another'}); + await expect(addBtn).toBeVisible(); + + await doSubmit(); + await expect(args.onSubmit).toHaveBeenCalledWith({ + openForms: {dataSrc: 'manual'}, + values: [{value: '', label: '', openForms: {translations: {}}}], + }); + // @ts-expect-error jest mocks + TS doesn't play nice together + args.onSubmit.mockClear(); + }); + + await step('Set variable source', async () => { + // Open the dropdown + const dataSrcSelect = canvas.getByLabelText('Data source'); + await userEvent.click(dataSrcSelect); + await userEvent.keyboard('[ArrowDown]'); + + await userEvent.click(await canvas.findByText('From variable')); + + const expressionInput = await canvas.findByLabelText('Items expression'); + await userEvent.clear(expressionInput); + // { needs to be escaped: https://github.com/testing-library/user-event/issues/584 + const expression = '{"var": "someVar"}'.replace(/[{[]/g, '$&$&'); + await userEvent.type(expressionInput, expression); + + await doSubmit(); + await expect(args.onSubmit).toHaveBeenCalledWith({ + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'someVar'}, + }, + }); + // @ts-expect-error jest mocks + TS doesn't play nice together + args.onSubmit.mockClear(); + }); + + await step('Reset back to manual values', async () => { + // Open the dropdown + const dataSrcSelect = canvas.getByLabelText('Data source'); + await userEvent.click(dataSrcSelect); + await userEvent.keyboard('[ArrowDown]'); + + await userEvent.click(await canvas.findByText('Manually fill in')); + await expect(await canvas.findByText('Manually fill in')).toBeVisible(); + + await doSubmit(); + await expect(args.onSubmit).toHaveBeenCalledWith({ + openForms: {dataSrc: 'manual'}, + // all pre-existing items have been cleared and we ensure there's at least 1 item + values: [{value: '', label: '', openForms: {translations: {}}}], + }); + // @ts-expect-error jest mocks + TS doesn't play nice together + args.onSubmit.mockClear(); + }); + }, +}; + +/** + * Variant pinned to the `RadioComponentSchema` component type. + */ +export const Radio: RadioStory = { + decorators: [withFormik], +}; + +export const RadioManual: RadioStory = { + decorators: [withFormik], + parameters: { + formik: { + initialValues: { + openForms: { + dataSrc: 'manual', + }, + values: [ + { + value: 'a', + label: 'A', + }, + { + value: 'b', + label: 'B', + }, + ], + }, + }, + }, +}; + +export const Radioiable: RadioStory = { + decorators: [withFormik], + parameters: { + formik: { + initialValues: { + openForms: { + dataSrc: 'variable', + itemsExpression: {var: 'someVariable'}, + }, + }, + }, + }, +}; diff --git a/src/components/builder/values/values-config.tsx b/src/components/builder/values/values-config.tsx new file mode 100644 index 00000000..a5e4e40a --- /dev/null +++ b/src/components/builder/values/values-config.tsx @@ -0,0 +1,60 @@ +import {useFormikContext} from 'formik'; +import {useLayoutEffect} from 'react'; + +import ItemsExpression from './items-expression'; +import {SchemaWithDataSrc} from './types'; +import ValuesSrc from './values-src'; +import ValuesTable, {ValuesTableProps} from './values-table'; + +export interface ValuesConfigProps { + name: ValuesTableProps['name']; +} + +/** + * The `ValuesConfig` component allows a form builder to specify available options. + * + * Certain component types like dropdowns, radio fields and multi-option fields present + * a pre-configured list of available options to the end-user. This component is used to + * do this pre-configuration. + * + * Options can either be provided manually upfront, or they can be set dynamically by + * referencing other variables in the form evaluation context. + */ +export function ValuesConfig({name}: ValuesConfigProps) { + const {values, setFieldValue} = useFormikContext(); + const {dataSrc} = values.openForms; + + // synchronize form state with the dataSrc value, and ensure this is done *before* the + // browser repaints to prevent race conditions + useLayoutEffect(() => { + switch (dataSrc) { + case 'manual': { + if (values.openForms.hasOwnProperty('itemsExpression')) { + setFieldValue('openForms.itemsExpression', undefined); + } + if (!values.hasOwnProperty(name)) { + setFieldValue(name, [{value: '', label: '', openForms: {translations: {}}}]); + } + break; + } + case 'variable': { + if (values.hasOwnProperty(name)) { + setFieldValue(name, undefined); + } + break; + } + } + // deliberate that we only provide dataSrc as dependency, the hook should only run + // when that dropdown changes value. + }, [dataSrc]); + + return ( + <> + + {dataSrc === 'manual' && name={name} />} + {dataSrc === 'variable' && } + + ); +} + +export default ValuesConfig; diff --git a/src/components/builder/values/values-src.stories.ts b/src/components/builder/values/values-src.stories.ts new file mode 100644 index 00000000..9e3fc72a --- /dev/null +++ b/src/components/builder/values/values-src.stories.ts @@ -0,0 +1,27 @@ +import {Meta, StoryObj} from '@storybook/react'; + +import {withFormik} from '@/sb-decorators'; + +import ValuesSrc from './values-src'; + +export default { + title: 'Formio/Builder/Values/ValuesSrc', + component: ValuesSrc, + decorators: [withFormik], + parameters: { + controls: {hideNoControlsWarning: true}, + modal: {noModal: true}, + formik: { + initialValues: { + openForms: { + dataSrc: '', + }, + }, + }, + }, + tags: ['autodocs'], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/components/builder/values/values-src.tsx b/src/components/builder/values/values-src.tsx new file mode 100644 index 00000000..bc9ed159 --- /dev/null +++ b/src/components/builder/values/values-src.tsx @@ -0,0 +1,60 @@ +import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; + +import {Select} from '@/components/formio'; + +import {OptionValue} from './types'; + +const OPTION_LABELS = defineMessages({ + manual: { + description: "Data source option label for value 'manual'", + defaultMessage: 'Manually fill in', + }, + variable: { + description: "Data source option label for value 'variable'", + defaultMessage: 'From variable', + }, +}); + +// define the values with the the desired correct order +const OPTION_VALUES = ['manual', 'variable'] as const; + +/** + * The `ValuesSrc` component is used to configure on the component where options/values + * are sourced from. + * + * The available options can be specified manually, or they can be derived from another + * existing 'variable' through a JsonLogic expression. + * + * This component requires a compatible schema like `SelectComponentSchema`, + * `RadioComponentSchema` or `SelectboxesComponentSchema`. + * + * @todo: on change, the *other* configuration aspect needs to be cleared/reset. + */ +export const ValuesSrc: React.FC = () => { + const intl = useIntl(); + const options = OPTION_VALUES.map(val => ({ + value: val, + label: intl.formatMessage(OPTION_LABELS[val]), + })); + const tooltip = intl.formatMessage({ + description: "Tooltip for 'openForms.dataSrc' builder field", + defaultMessage: 'How to specify the available options.', + }); + return ( +