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 ` +
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') }} + +