Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement soft required validation #726

Merged
merged 5 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(

Check warning on line 8 in src/formio/components/SoftRequiredErrors.js

View check run for this annotation

Codecov / codecov/patch

src/formio/components/SoftRequiredErrors.js#L7-L8

Added lines #L7 - L8 were not covered by tests
{
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},
sergei-maertens marked this conversation as resolved.
Show resolved Hide resolved
},
{
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