Skip to content

Commit

Permalink
Merge pull request #726 from open-formulieren/feature/4546-soft-requi…
Browse files Browse the repository at this point in the history
…red-validation

Implement soft required validation
  • Loading branch information
sergei-maertens authored Oct 29, 2024
2 parents 177f1cc + 8b339c5 commit 6352de4
Show file tree
Hide file tree
Showing 11 changed files with 358 additions and 25 deletions.
17 changes: 13 additions & 4 deletions src/formio/components/FileField.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions src/formio/components/SoftRequiredErrors.js
Original file line number Diff line number Diff line change
@@ -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 `
<div
id="${this.id}-content"
class="utrecht-alert utrecht-alert--warning openforms-soft-required-errors"
role="status"
>
<div class="utrecht-alert__icon">
<i class="fa fas fa-exclamation-triangle"></i>
</div>
<div class="utrecht-alert__message">
${content}
</div>
</div>
`;
}
}

export default SoftRequiredErrors;
111 changes: 111 additions & 0 deletions src/formio/components/SoftRequiredErrors.stories.js
Original file line number Diff line number Diff line change
@@ -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: `
<p>Not all required fields are filled out. That can get expensive!</p>
{{ missingFields }}
<p>Are you sure you want to continue?</p>
`,
},
],
},

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: `
<p>Not all required fields are filled out. That can get expensive!</p>
{{ missingFields }}
<p>Are you sure you want to continue?</p>
`,
},
],
},
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();
});
});
},
};
8 changes: 6 additions & 2 deletions src/formio/components/TextField.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions src/formio/components/TextField.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions src/formio/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,6 +56,7 @@ const FormIOModule = {
coSign: CoSignOld,
cosign: Cosign,
editgrid: EditGrid,
softRequiredErrors: SoftRequiredErrors,
},
providers: {
storage: {url: CSRFEnabledUrl},
Expand Down
2 changes: 2 additions & 0 deletions src/formio/templates/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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},
Expand Down
8 changes: 8 additions & 0 deletions src/formio/templates/missingFields.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<span class="sr-only" id="{{ctx.id}}-missing-fields-header">
{{ ctx.t('Empty fields') }}
</span>
<ul class="utrecht-unordered-list openforms-soft-required-errors__missing-fields" aria-labelledby="{{ctx.id}}-missing-fields-header">
{% for (const label of ctx.labels) { %}
<li class="utrecht-unordered-list__item">{{ label }}</li>
{% } %}
</ul>
Loading

0 comments on commit 6352de4

Please sign in to comment.