diff --git a/src/formio/components/Checkbox.spec.js b/src/formio/components/Checkbox.spec.js new file mode 100644 index 000000000..437e89b12 --- /dev/null +++ b/src/formio/components/Checkbox.spec.js @@ -0,0 +1,75 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const selectboxesForm = { + type: 'form', + components: [ + { + key: 'checkbox', + type: 'checkbox', + label: 'Checkbox', + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async () => { + let formJSON = _.cloneDeep(selectboxesForm); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The checkbox component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Checkbox component required and checked', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const checkbox = screen.getByLabelText('Checkbox'); + + expect(checkbox).toBeVisible(); + + await user.click(checkbox); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Checkbox component required without being checked', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const checkbox = screen.getByLabelText('Checkbox'); + + // Check and uncheck the checkbox to trigger the validation + await user.click(checkbox); + await user.click(checkbox); + + // All selectboxes are marked as invalid and have aria-describedby and aria-invalid + expect(checkbox).toHaveClass('is-invalid'); + expect(checkbox).toHaveAttribute('aria-describedby'); + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.click(checkbox); + + // All checkboxes are again marked as valid and without aria-describedby and aria-invalid + expect(checkbox).not.toHaveClass('is-invalid'); + expect(checkbox).not.toHaveAttribute('aria-describedby'); + expect(checkbox).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Currency.spec.js b/src/formio/components/Currency.spec.js new file mode 100644 index 000000000..e78e5e09c --- /dev/null +++ b/src/formio/components/Currency.spec.js @@ -0,0 +1,77 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const currencyForm = { + type: 'form', + components: [ + { + key: 'currency', + type: 'currency', + label: 'Currency', + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async () => { + let formJSON = _.cloneDeep(currencyForm); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The currency component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single currency component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const input = screen.getByLabelText('Currency'); + + expect(input).toBeVisible(); + + await user.type(input, '6'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single currency component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const input = screen.getByLabelText('Currency'); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '6'); + 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/components/DateField.spec.js b/src/formio/components/DateField.spec.js new file mode 100644 index 000000000..23ce22e2d --- /dev/null +++ b/src/formio/components/DateField.spec.js @@ -0,0 +1,229 @@ +import {waitFor} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import 'flatpickr'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; +import {sleep} from 'utils'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const dateForm = { + type: 'form', + components: [ + { + key: 'date', + type: 'date', + label: 'Date', + format: 'dd-MM-yyyy', + validate: { + required: true, + }, + }, + ], +}; + +const multipleDateForm = { + type: 'form', + components: [ + { + key: 'date-multiple', + type: 'date', + label: 'Multiple date', + format: 'dd-MM-yyyy', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +const waitForFlatpickr = async node => { + let calendarNode; + for (let i = 0; i < 20; i++) { + calendarNode = node.querySelector('.flatpickr-calendar'); + if (calendarNode !== null) return; + await sleep(100); + } +}; + +describe('The date component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single date component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Single date component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + expect(input).toBeVisible(); + + // Trigger validation + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + // Input is again valid and without aria-describedby and aria-invalid + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); + +describe('The multiple date component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple date component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Multiple date component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + // Trigger validation + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + // The field is again valid + + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); + + test('Multiple date without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }, 10000); + + test('Multiple date with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '08-08-2024 14:45'); + await user.type(inputs[1], '08-08-2024 14:45'); + await user.click(container); + + // The Both inputs are valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + await user.clear(inputs[0]); + await user.click(container); + + // Only the invalid input is marked as invalid + await waitFor(async () => { + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + }); + + await user.type(inputs[0], '16-08-2024'); + await user.tab({shift: true}); + + // Both inputs are again valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); diff --git a/src/formio/components/DateTimeField.spec.js b/src/formio/components/DateTimeField.spec.js new file mode 100644 index 000000000..aecf39041 --- /dev/null +++ b/src/formio/components/DateTimeField.spec.js @@ -0,0 +1,229 @@ +import {waitFor} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import 'flatpickr'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; +import {sleep} from 'utils'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const dateTimeForm = { + type: 'form', + components: [ + { + key: 'dateTime', + type: 'datetime', + label: 'Datetime', + format: 'dd-MM-yyyy HH:mm', + validate: { + required: true, + }, + }, + ], +}; + +const multipleDateTimeForm = { + type: 'form', + components: [ + { + key: 'datetime-multiple', + type: 'datetime', + label: 'Multiple datetime', + format: 'dd-MM-yyyy HH:mm', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +const waitForFlatpickr = async node => { + let calendarNode; + for (let i = 0; i < 20; i++) { + calendarNode = node.querySelector('.flatpickr-calendar'); + if (calendarNode !== null) return; + await sleep(100); + } +}; + +describe('The datetime component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single datetime component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Single datetime component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + expect(input).toBeVisible(); + + // Trigger validation + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + // Input is again valid and without aria-describedby and aria-invalid + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); + +describe('The multiple datetime component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple datetime component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Multiple datetime component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + // Trigger validation + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + // The field is again valid + + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); + + test('Multiple datetime without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }, 10000); + + test('Multiple datetime with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '08-08-2024 14:45'); + await user.type(inputs[1], '08-08-2024 14:45'); + await user.click(container); + + // The Both inputs are valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + await user.clear(inputs[0]); + await user.click(container); + + // Only the invalid input is marked as invalid + await waitFor(async () => { + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + }); + + await user.type(inputs[0], '16-08-2024 14:45'); + await user.tab({shift: true}); + + // Both inputs are again valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); diff --git a/src/formio/components/Number.spec.js b/src/formio/components/Number.spec.js new file mode 100644 index 000000000..93842c76c --- /dev/null +++ b/src/formio/components/Number.spec.js @@ -0,0 +1,192 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const numberForm = { + type: 'form', + components: [ + { + key: 'number', + type: 'number', + label: 'Number', + validate: { + required: true, + }, + }, + ], +}; + +const multipleNumberForm = { + type: 'form', + components: [ + { + key: 'number', + type: 'number', + label: 'Multiple number', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The number component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single number component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(numberForm); + + const input = screen.getByLabelText('Number'); + + expect(input).toBeVisible(); + + await user.type(input, '6'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single number component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(numberForm); + + const input = screen.getByLabelText('Number'); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '6'); + 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(); + }); +}); + +describe('The multiple number component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple number component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + const input = screen.getByLabelText('Multiple number'); + + expect(input).toBeVisible(); + + await user.type(input, '6'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple number component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + const input = screen.getByLabelText('Multiple number'); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '6'); + await user.tab({shift: true}); + + // The field is again valid + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple number without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + const input = screen.getByLabelText('Multiple number'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple number with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '6'); + await user.type(inputs[1], '12'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], '3'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Password.spec.js b/src/formio/components/Password.spec.js new file mode 100644 index 000000000..f249fd7ba --- /dev/null +++ b/src/formio/components/Password.spec.js @@ -0,0 +1,194 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getAllChildInputs, getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const passwordForm = { + type: 'form', + components: [ + { + key: 'password', + type: 'password', + label: 'Password', + validate: { + required: true, + }, + }, + ], +}; + +const multiplePasswordForm = { + type: 'form', + components: [ + { + key: 'password', + type: 'password', + label: 'Multiple password', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The password component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single password component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(passwordForm); + + const input = screen.getByLabelText('Password'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single password component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(passwordForm); + + const input = screen.getByLabelText('Password'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + 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(); + }); +}); + +describe('The multiple password component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple password component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + const input = screen.getByLabelText('Multiple password'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple password component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + const input = screen.getByLabelText('Multiple password'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, 'foo'); + await user.tab({shift: true}); + + // The field is again valid + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple password without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + const input = screen.getByLabelText('Multiple password'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple password with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + // Password inputs cannot be found using `getByRole` https://github.com/testing-library/dom-testing-library/issues/1128#issuecomment-1125662009 + const component = getComponentNode(screen.getByLabelText('Multiple password')); + const inputs = getAllChildInputs(component); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], 'foo'); + await user.type(inputs[1], 'bar'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], 'foo bar'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Selectboxes.spec.js b/src/formio/components/Selectboxes.spec.js new file mode 100644 index 000000000..b3a85ce41 --- /dev/null +++ b/src/formio/components/Selectboxes.spec.js @@ -0,0 +1,95 @@ +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const selectboxesForm = { + type: 'form', + components: [ + { + key: 'selectboxes', + type: 'selectboxes', + label: 'Selectboxes', + values: [ + { + label: 'Optie A', + value: 'selectA', + }, + { + label: 'Optie B', + value: 'selectB', + }, + ], + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async () => { + let formJSON = _.cloneDeep(selectboxesForm); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The selectboxes component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Selectboxes component checked', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const selectboxA = screen.getByLabelText('Optie A'); + const selectboxB = screen.getByLabelText('Optie B'); + + expect(selectboxA).toBeVisible(); + expect(selectboxB).toBeVisible(); + + await user.click(selectboxA); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Selectboxes component without checked selectbox', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const selectboxA = screen.getByLabelText('Optie A'); + const selectboxB = screen.getByLabelText('Optie B'); + + // Check and uncheck selectboxA to trigger the validation + await user.click(selectboxA); + await user.click(selectboxA); + + // All selectboxes are marked as invalid and have aria-describedby and aria-invalid + expect(selectboxA).toHaveClass('is-invalid'); + expect(selectboxA).toHaveAttribute('aria-describedby'); + expect(selectboxA).toHaveAttribute('aria-invalid', 'true'); + expect(selectboxB).toHaveClass('is-invalid'); + expect(selectboxB).toHaveAttribute('aria-describedby'); + expect(selectboxB).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.click(selectboxB); + + // All checkboxes are again marked as valid and without aria-describedby and aria-invalid + expect(selectboxA).not.toHaveClass('is-invalid'); + expect(selectboxA).not.toHaveAttribute('aria-describedby'); + expect(selectboxA).not.toHaveAttribute('aria-invalid'); + expect(selectboxB).not.toHaveClass('is-invalid'); + expect(selectboxB).not.toHaveAttribute('aria-describedby'); + expect(selectboxB).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/TextField.spec.js b/src/formio/components/TextField.spec.js new file mode 100644 index 000000000..3d0e35579 --- /dev/null +++ b/src/formio/components/TextField.spec.js @@ -0,0 +1,187 @@ +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const textfieldForm = { + type: 'form', + components: [ + { + key: 'textfield-single', + type: 'textfield', + label: 'Text', + validate: { + required: true, + }, + }, + ], +}; + +const multipleTextfieldForm = { + type: 'form', + components: [ + { + key: 'textfield-multiple', + type: 'textfield', + label: 'Text list', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The text component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single textfield component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textfieldForm); + + const input = screen.getByLabelText('Text'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single textfield component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textfieldForm); + + 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).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + 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(); + }); +}); + +describe('The mutiple text component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple textfield component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + const multipleInput = screen.getByLabelText('Text list'); + + expect(multipleInput).toBeVisible(); + + await user.type(multipleInput, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple textfield component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + const multipleInput = screen.getByLabelText('Text list'); + + // Trigger validation + await user.type(multipleInput, 'foo'); + await user.clear(multipleInput); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(multipleInput).toHaveClass('is-invalid'); + expect(multipleInput).not.toHaveAttribute('aria-describedby'); + expect(multipleInput).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple textfield without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + const multipleInput = screen.getByLabelText('Text list'); + const multipleTextComponent = getComponentNode(multipleInput); + + // Trigger validation + await user.type(multipleInput, 'foo'); + await user.clear(multipleInput); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(multipleTextComponent).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple textfield with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], 'foo'); + await user.type(inputs[1], 'bar'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], 'foo bar'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Textarea.spec.js b/src/formio/components/Textarea.spec.js new file mode 100644 index 000000000..0136c7c90 --- /dev/null +++ b/src/formio/components/Textarea.spec.js @@ -0,0 +1,193 @@ +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const textareaForm = { + type: 'form', + components: [ + { + key: 'textarea-single', + type: 'textarea', + label: 'Textarea', + validate: { + required: true, + }, + }, + ], +}; + +const multipleTextareaForm = { + type: 'form', + components: [ + { + key: 'textarea-multiple', + type: 'textarea', + label: 'Multiple textarea', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The textarea component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single textarea component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textareaForm); + + const input = screen.getByLabelText('Textarea'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single textarea component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textareaForm); + + const input = screen.getByLabelText('Textarea'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + 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(); + }); +}); + +describe('The multiple textarea component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple textarea component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + const input = screen.getByLabelText('Multiple textarea'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple textfield component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + const input = screen.getByLabelText('Multiple textarea'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, 'foo'); + await user.tab({shift: true}); + + // The field is again valid + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple textfield without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + const input = screen.getByLabelText('Multiple textarea'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple textfield with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], 'foo'); + await user.type(inputs[1], 'bar'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], 'foo bar'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/TimeField.spec.js b/src/formio/components/TimeField.spec.js index 9aee5345b..b20b53196 100644 --- a/src/formio/components/TimeField.spec.js +++ b/src/formio/components/TimeField.spec.js @@ -1,14 +1,16 @@ -import {screen, waitFor} from '@testing-library/dom'; +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import _ from 'lodash'; import {Formio} from 'react-formio'; +import {getComponentNode} from 'formio/components/jest-util'; import OpenFormsModule from 'formio/module'; // Use our custom components Formio.use(OpenFormsModule); -const phoneForm = { +const timeForm = { type: 'form', components: [ { @@ -21,8 +23,25 @@ const phoneForm = { ], }; -const renderForm = async () => { - let formJSON = _.cloneDeep(phoneForm); +const multipleTimeForm = { + type: 'form', + components: [ + { + key: 'time', + type: 'time', + input: true, + label: 'Time', + inputType: 'text', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); const container = document.createElement('div'); document.body.appendChild(container); const form = await Formio.createForm(container, formJSON); @@ -36,7 +55,7 @@ describe('The time component', () => { test('Time component with invalid time', async () => { const user = userEvent.setup({delay: 50}); - const {form} = await renderForm(); + const {form} = await renderForm(timeForm); const input = screen.getByLabelText('Time'); expect(input).toBeVisible(); @@ -45,4 +64,129 @@ describe('The time component', () => { expect(form.isValid()).toBe(false); }); + + test('Time component with invalid time has descriptive aria tags', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(timeForm); + + const input = screen.getByLabelText('Time'); + expect(input).toBeVisible(); + + // Valid time input + await user.type(input, '12:00'); + expect(form.isValid()).toBeTruthy(); + + // Invalid time input + await user.clear(input); + await user.type(input, '25:00'); + + // Expect the invalid time input to have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + // Change time input to a valid time + await user.clear(input); + await user.type(input, '12:00'); + + // Expect the valid time input to not have 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(); + }); +}); + +describe('The time component multiple', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple time component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + const multipleInput = screen.getByLabelText('Time'); + + expect(multipleInput).toBeVisible(); + + await user.type(multipleInput, '12:00'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple time component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + const input = screen.getByLabelText('Time'); + + // Trigger validation + await user.type(input, '25:00'); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + + expect(form.isValid()).toBeFalsy(); + }); + + test('Required multiple time without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + const input = screen.getByLabelText('Time'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '25:00'); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple time with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '12:00'); + await user.type(inputs[1], '12:00'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.type(inputs[0], '25:00'); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.clear(inputs[0]); + await user.type(inputs[0], '12:00'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); }); diff --git a/src/formio/components/jest-util.js b/src/formio/components/jest-util.js new file mode 100644 index 000000000..2da3ff149 --- /dev/null +++ b/src/formio/components/jest-util.js @@ -0,0 +1,5 @@ +const getComponentNode = element => element.closest('[ref="component"]'); + +const getAllChildInputs = element => element.querySelectorAll('input'); + +export {getAllChildInputs, getComponentNode};