diff --git a/src/formio/components/FileField.js b/src/formio/components/FileField.js index 906d341a9..8b4abf02b 100644 --- a/src/formio/components/FileField.js +++ b/src/formio/components/FileField.js @@ -4,7 +4,7 @@ import {Formio} from 'react-formio'; import {CSRFToken} from 'headers'; -import {applyPrefix, setErrorAttributes} from '../utils'; +import {applyPrefix, linkToSoftRequiredDisplay, setErrorAttributes} from '../utils'; const addCSRFToken = xhr => { const csrfTokenValue = CSRFToken.getValue(); @@ -291,9 +291,13 @@ class FileField extends Formio.Components.components.file { return super.validatePattern(file, val); } - setErrorClasses(elements, dirty, hasErrors, hasMessages) { + _getTargetElements() { const input = this.refs.fileBrowse; - const targetElements = input ? [input] : []; + return input ? [input] : []; + } + + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + const targetElements = this._getTargetElements(); setErrorAttributes(targetElements, hasErrors, hasMessages, this.refs.messageContainer.id); return super.setErrorClasses(targetElements, dirty, hasErrors, hasMessages); } @@ -313,7 +317,12 @@ class FileField extends Formio.Components.components.file { return false; } - return super.checkComponentValidity(data, dirty, row, options); + const result = super.checkComponentValidity(data, dirty, row, options); + + const targetElements = this._getTargetElements(); + linkToSoftRequiredDisplay(targetElements, this); + + return result; } deleteFile(fileInfo) { diff --git a/src/formio/components/SoftRequiredErrors.js b/src/formio/components/SoftRequiredErrors.js new file mode 100644 index 000000000..d6c40b860 --- /dev/null +++ b/src/formio/components/SoftRequiredErrors.js @@ -0,0 +1,63 @@ +import FormioUtils from 'formiojs/utils'; +import {Formio} from 'react-formio'; + +const FormioContentField = Formio.Components.components.content; + +class SoftRequiredErrors extends FormioContentField { + static schema(...extend) { + return FormioContentField.schema( + { + type: 'softRequiredErrors', + key: 'softRequiredErrors', + label: '', // not displayed anyway + }, + ...extend + ); + } + + constructor(component, options, data) { + component.refreshOnChange = true; + super(component, options, data); + } + + get content() { + // figure out which components use soft required validation + const softRequiredComponents = []; + FormioUtils.eachComponent(this.root.components, component => { + if (component.component.openForms?.softRequired) { + softRequiredComponents.push(component); + } + }); + + const missingFieldLabels = []; + // check which components have an empty value + for (const component of softRequiredComponents) { + const isEmpty = component.isEmpty(); + if (isEmpty) missingFieldLabels.push(component.label); + } + + if (!missingFieldLabels.length) return ''; + + const missingFieldsMarkup = this.renderTemplate('missingFields', {labels: missingFieldLabels}); + const content = this.interpolate(this.component.html, { + missingFields: missingFieldsMarkup, + }); + + return ` +
+
+ +
+
+ ${content} +
+
+ `; + } +} + +export default SoftRequiredErrors; diff --git a/src/formio/components/SoftRequiredErrors.stories.js b/src/formio/components/SoftRequiredErrors.stories.js new file mode 100644 index 000000000..d5b3a8479 --- /dev/null +++ b/src/formio/components/SoftRequiredErrors.stories.js @@ -0,0 +1,111 @@ +import {expect, userEvent, waitFor, within} from '@storybook/test'; + +import {withUtrechtDocument} from 'story-utils/decorators'; +import {sleep} from 'utils'; + +import {UPLOAD_URL, mockFileUploadDelete, mockFileUploadPost} from './FileField.mocks'; +import {MultipleFormioComponents} from './story-util'; + +export default { + title: 'Form.io components / Custom / SoftRequiredErrors', + decorators: [withUtrechtDocument], + render: MultipleFormioComponents, + args: { + components: [ + { + type: 'file', + key: 'file', + storage: 'url', + url: UPLOAD_URL, + label: 'Soft required file', + multiple: false, + openForms: {softRequired: true}, + }, + { + type: 'textfield', + key: 'textfield', + label: 'Soft required text', + openForms: {softRequired: true}, + }, + { + type: 'softRequiredErrors', + html: ` +

Not all required fields are filled out. That can get expensive!

+ + {{ missingFields }} + +

Are you sure you want to continue?

+ `, + }, + ], + }, + + argTypes: { + components: {table: {disable: true}}, + evalContext: {table: {disable: true}}, + }, + parameters: { + controls: {sort: 'requiredFirst'}, + msw: { + handlers: [mockFileUploadPost, mockFileUploadDelete], + }, + }, +}; + +export const EmptyFields = { + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + await canvas.findByText('Not all required fields are filled out. That can get expensive!'); + const list = await canvas.findByRole('list', {name: 'Empty fields'}); + const listItems = within(list).getAllByRole('listitem'); + expect(listItems).toHaveLength(2); + const content = listItems.map(item => item.textContent); + expect(content).toEqual(['Soft required file', 'Soft required text']); + }, +}; + +export const FillField = { + args: { + components: [ + { + type: 'textfield', + key: 'textfield', + label: 'Soft required text', + openForms: {softRequired: true}, + }, + { + type: 'softRequiredErrors', + html: ` +

Not all required fields are filled out. That can get expensive!

+ + {{ missingFields }} + +

Are you sure you want to continue?

+ `, + }, + ], + }, + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + // formio... :thisisfine: + await sleep(100); + + const ERROR_TEXT = 'Not all required fields are filled out. That can get expensive!'; + + await step('Initial state', async () => { + expect(await canvas.findByText(ERROR_TEXT)).toBeVisible(); + const list = await canvas.findByRole('list', {name: 'Empty fields'}); + const listItems = within(list).getAllByRole('listitem'); + expect(listItems).toHaveLength(1); + }); + + await step('Fill out field and remove error', async () => { + const input = canvas.getByLabelText('Soft required text'); + await userEvent.type(input, 'Not empty'); + await waitFor(() => { + expect(canvas.queryByText(ERROR_TEXT)).toBeNull(); + }); + }); + }, +}; diff --git a/src/formio/components/TextField.js b/src/formio/components/TextField.js index 86c513e1b..6e510c2db 100644 --- a/src/formio/components/TextField.js +++ b/src/formio/components/TextField.js @@ -2,7 +2,7 @@ import debounce from 'lodash/debounce'; import {Formio} from 'react-formio'; import {get} from '../../api'; -import {setErrorAttributes} from '../utils'; +import {linkToSoftRequiredDisplay, setErrorAttributes} from '../utils'; import enableValidationPlugins from '../validators/plugins'; const POSTCODE_REGEX = /^[0-9]{4}\s?[a-zA-Z]{2}$/; @@ -35,7 +35,11 @@ class TextField extends Formio.Components.components.textfield { if (this.component.validate.plugins && this.component.validate.plugins.length) { updatedOptions.async = true; } - return super.checkComponentValidity(data, dirty, row, updatedOptions); + const result = super.checkComponentValidity(data, dirty, row, updatedOptions); + + linkToSoftRequiredDisplay(this.refs.input, this); + + return result; } setErrorClasses(elements, dirty, hasErrors, hasMessages) { diff --git a/src/formio/components/TextField.spec.js b/src/formio/components/TextField.spec.js index 3d0e35579..df0caafac 100644 --- a/src/formio/components/TextField.spec.js +++ b/src/formio/components/TextField.spec.js @@ -185,3 +185,61 @@ describe('The mutiple text component', () => { expect(form.isValid()).toBeTruthy(); }); }); + +// This is not officially supported yet... but due to the generic implementation it +// works. It is/was intended for file fields only at first. +describe('Textfield with soft required validation', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('The softRequiredErrors component is linked', async () => { + const user = userEvent.setup({delay: 50}); + const FORM = { + type: 'form', + components: [ + { + type: 'textfield', + key: 'textfield', + label: 'Text', + validate: {required: false}, + openForms: {softRequired: true}, + }, + { + id: 'softReq123', + type: 'softRequiredErrors', + key: 'softRequiredErrors', + html: `{{ missingFields }}`, + }, + ], + }; + const {form} = await renderForm(FORM); + + const input = screen.getByLabelText('Text'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + // Lose focus of input + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).not.toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeTruthy(); + + // check that it points to a real div + const expectedDiv = document.getElementById(input.getAttribute('aria-describedby')); + expect(expectedDiv).not.toBeNull(); + + await user.type(input, 'foo'); + await user.tab({shift: true}); + + // Input is again valid and without aria-describedby and aria-invalid + expect(input).not.toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/module.js b/src/formio/module.js index bb6e5b101..1879815f5 100644 --- a/src/formio/module.js +++ b/src/formio/module.js @@ -22,6 +22,7 @@ import PostcodeField from './components/PostcodeField'; import Radio from './components/Radio'; import Select from './components/Select'; import Selectboxes from './components/Selectboxes'; +import SoftRequiredErrors from './components/SoftRequiredErrors'; import TextArea from './components/TextArea'; import TextField from './components/TextField'; import TimeField from './components/TimeField'; @@ -55,6 +56,7 @@ const FormIOModule = { coSign: CoSignOld, cosign: Cosign, editgrid: EditGrid, + softRequiredErrors: SoftRequiredErrors, }, providers: { storage: {url: CSRFEnabledUrl}, diff --git a/src/formio/templates/library.js b/src/formio/templates/library.js index 040f5ae54..8d856c82e 100644 --- a/src/formio/templates/library.js +++ b/src/formio/templates/library.js @@ -10,6 +10,7 @@ import {default as FieldSetTemplate} from './fieldset.ejs'; import {default as FileTemplate} from './file.ejs'; import {default as LabelTemplate} from './label.ejs'; import {default as MapTemplate} from './map.ejs'; +import {default as MissingFieldsTemplate} from './missingFields.ejs'; import {default as MultiValueRowTemplate} from './multiValueRow.ejs'; import {default as MultiValueTableTemplate} from './multiValueTable.ejs'; import {default as RadioTemplate} from './radio.ejs'; @@ -20,6 +21,7 @@ import {default as TextTemplate} from './text.ejs'; const OFLibrary = { component: {form: ComponentTemplate}, field: {form: FieldTemplate}, // wrapper around the individual field types + missingFields: {form: MissingFieldsTemplate}, button: {form: ButtonTemplate}, checkbox: {form: CheckboxTemplate}, diff --git a/src/formio/templates/missingFields.ejs b/src/formio/templates/missingFields.ejs new file mode 100644 index 000000000..7790e35b1 --- /dev/null +++ b/src/formio/templates/missingFields.ejs @@ -0,0 +1,8 @@ + + {{ ctx.t('Empty fields') }} + + diff --git a/src/formio/utils.js b/src/formio/utils.js index f91e819b6..772ec68ad 100644 --- a/src/formio/utils.js +++ b/src/formio/utils.js @@ -1,3 +1,5 @@ +import FormioUtils from 'formiojs/utils'; + import {PREFIX} from './constants'; /** @@ -14,28 +16,64 @@ const escapeHtml = source => { return pre.innerHTML.replace(/"/g, '"').replace(/'/g, ''').replace(/&/g, '&'); }; -const setErrorAttributes = (elements, hasErrors, hasMessages, messageContainerId) => { - // Update the attributes 'aria-invalid' and 'aria-describedby' using hasErrors - elements.forEach(element => { - let ariaDescriptions = (element.getAttribute('aria-describedby') || '') - .split(' ') - .filter(description => !!description); +const getAriaDescriptions = element => + (element.getAttribute('aria-describedby') || '').split(' ').filter(description => !!description); - if (hasErrors && hasMessages && !ariaDescriptions.includes(messageContainerId)) { - // The input has an error, but the error message isn't yet part of the ariaDescriptions - ariaDescriptions.push(messageContainerId); - } +/** + * Set or remove description IDs from the element's `aria-describedby` attribute. + * @param {HTMLElement} element The element to update. + * @param {string[]} descriptionIds Element IDs to set or remove. + * @param {'present' | 'absent'} state Desired state + * @return {void} + */ +const updateAriaDescriptions = (element, descriptionIds, state) => { + let ariaDescriptions = getAriaDescriptions(element); - if (!hasErrors && ariaDescriptions.includes(messageContainerId)) { - // The input doesn't have an error, but the error message is still a part of the ariaDescriptions - ariaDescriptions = ariaDescriptions.filter(description => description !== messageContainerId); + switch (state) { + case 'absent': { + ariaDescriptions = ariaDescriptions.filter( + description => !descriptionIds.includes(description) + ); + break; } - - if (ariaDescriptions.length > 0) { - element.setAttribute('aria-describedby', ariaDescriptions.join(' ')); - } else { - element.removeAttribute('aria-describedby'); + case 'present': { + const idsToAdd = descriptionIds.filter( + description => !ariaDescriptions.includes(description) + ); + ariaDescriptions.push(...idsToAdd); + break; } + default: { + throw new Error(`Unknown state: ${state}`); + } + } + + if (ariaDescriptions.length > 0) { + element.setAttribute('aria-describedby', ariaDescriptions.join(' ')); + } else { + element.removeAttribute('aria-describedby'); + } +}; + +/** + * Update the accessible error attributes for the input elements + * @param {HTMLElement[]} elements + * @param {Boolean} hasErrors + * @param {Boolean} hasMessages + * @param {string} messageContainerId + * @return {void} + */ +const setErrorAttributes = (elements, hasErrors, hasMessages, messageContainerId) => { + // Update the attributes 'aria-invalid' and 'aria-describedby' using hasErrors + elements.forEach(element => { + const desiredState = + hasErrors & hasMessages + ? // The input has an error, but the error message isn't yet part of the ariaDescriptions + 'present' + : // The input doesn't have an error, but the error message is still a part of the ariaDescriptions + 'absent'; + + updateAriaDescriptions(element, [messageContainerId], desiredState); if (hasErrors) { element.setAttribute('aria-invalid', 'true'); @@ -45,4 +83,24 @@ const setErrorAttributes = (elements, hasErrors, hasMessages, messageContainerId }); }; -export {applyPrefix, escapeHtml, setErrorAttributes}; +const linkToSoftRequiredDisplay = (elements, component) => { + // if soft required validation is not enabled, there's nothing to do + if (!component.component.openForms?.softRequired) return; + + const isEmpty = component.isEmpty(); + + const softRequiredIds = []; + FormioUtils.eachComponent(component.root.components, component => { + if (component.type === 'softRequiredErrors') { + const id = `${component.id}-content`; + softRequiredIds.push(id); + } + }); + + // Update the attribute 'aria-describedby' based on whether the component is empty + elements.forEach(element => { + updateAriaDescriptions(element, softRequiredIds, isEmpty ? 'present' : 'absent'); + }); +}; + +export {applyPrefix, escapeHtml, setErrorAttributes, linkToSoftRequiredDisplay}; diff --git a/src/scss/components/_soft-required-errors.scss b/src/scss/components/_soft-required-errors.scss new file mode 100644 index 000000000..11638615e --- /dev/null +++ b/src/scss/components/_soft-required-errors.scss @@ -0,0 +1,17 @@ +@use 'microscope-sass/lib/bem'; + +@import '~microscope-sass/lib/typography'; + +.openforms-soft-required-errors { + // apply all wysiwyg styling + // TODO: parametrize this with design tokens -> check with NL DS how to approach this + @include wysiwyg; + + // unset, takes the utrecht-alert--warning by default, but can be overridden if desired + color: var(--of-soft-required-errors-color); + background-color: var(--of-soft-required-errors-background-color); + + @include bem.element('missing-fields') { + font-weight: var(--of-soft-required-errors-missing-fields-font-weight, 600); + } +} diff --git a/src/styles.scss b/src/styles.scss index c81f4777e..798b623ac 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -72,3 +72,4 @@ @import './scss/components/input-container'; @import './scss/components/authentication-errors'; @import './scss/components/govmetric_snippet'; +@import './scss/components/soft-required-errors';