diff --git a/aselo-webchat-react-app/package-lock.json b/aselo-webchat-react-app/package-lock.json
index 1a99023570..d933e00bbd 100644
--- a/aselo-webchat-react-app/package-lock.json
+++ b/aselo-webchat-react-app/package-lock.json
@@ -31416,23 +31416,6 @@
}
}
},
- "node_modules/tailwindcss/node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
diff --git a/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Checkbox.test.tsx b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Checkbox.test.tsx
new file mode 100644
index 0000000000..61b763033d
--- /dev/null
+++ b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Checkbox.test.tsx
@@ -0,0 +1,75 @@
+/**
+ * Copyright (C) 2021-2026 Technology Matters
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://www.gnu.org/licenses/.
+ */
+
+import { render, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { FormInputType } from 'hrm-form-definitions';
+
+import Checkbox from '../Checkbox';
+import { PreEngagementDataItem } from '../../../../store/definitions';
+
+jest.mock('../../../../localization/LocalizedTemplate', () => ({
+ __esModule: true,
+ default: ({ code }: { code: string }) => <>{code}>,
+}));
+
+describe('Checkbox component', () => {
+ const definition = {
+ name: 'terms',
+ type: FormInputType.Checkbox as FormInputType.Checkbox,
+ label: 'Accept terms',
+ };
+
+ const noError: PreEngagementDataItem = { value: false, error: null, dirty: false };
+ const withError: PreEngagementDataItem = { value: false, error: 'You must accept the terms', dirty: true };
+
+ const getItem = (item: PreEngagementDataItem) => (_name: string) => item;
+ const handleChange = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders with the correct label', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Accept terms')).toBeInTheDocument();
+ });
+
+ it('does not render an error message when error is null', () => {
+ const { queryByText } = render(
+ ,
+ );
+ expect(queryByText('You must accept the terms')).not.toBeInTheDocument();
+ });
+
+ it('renders an error message when error is not null', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('You must accept the terms')).toBeInTheDocument();
+ });
+
+ it('calls handleChange on blur with name and checked value', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const checkbox = getByRole('checkbox');
+ fireEvent.blur(checkbox, { target: { checked: true } });
+ expect(handleChange).toHaveBeenCalledWith({ name: 'terms', value: true });
+ });
+});
diff --git a/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/DependentSelect.test.tsx b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/DependentSelect.test.tsx
new file mode 100644
index 0000000000..5649dedf37
--- /dev/null
+++ b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/DependentSelect.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * Copyright (C) 2021-2026 Technology Matters
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://www.gnu.org/licenses/.
+ */
+
+import { render, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { FormInputType } from 'hrm-form-definitions';
+
+import DependentSelect from '../DependentSelect';
+import { PreEngagementDataItem } from '../../../../store/definitions';
+
+jest.mock('../../../../localization/LocalizedTemplate', () => ({
+ __esModule: true,
+ default: ({ code }: { code: string }) => <>{code}>,
+}));
+
+describe('DependentSelect component', () => {
+ const definition = {
+ name: 'state',
+ type: FormInputType.DependentSelect as FormInputType.DependentSelect,
+ label: 'State',
+ dependsOn: 'country',
+ options: {
+ US: [
+ { value: 'CA', label: 'California' },
+ { value: 'NY', label: 'New York' },
+ ],
+ UK: [{ value: 'ENG', label: 'England' }],
+ },
+ };
+
+ const noError: PreEngagementDataItem = { value: 'CA', error: null, dirty: false };
+ const withError: PreEngagementDataItem = { value: '', error: 'Please select a state', dirty: true };
+
+ const makeGetItem =
+ (stateItem: PreEngagementDataItem, countryValue: string) =>
+ (name: string): PreEngagementDataItem => {
+ if (name === 'country') return { value: countryValue, error: null, dirty: false };
+ return stateItem;
+ };
+
+ const handleChange = jest.fn();
+ const setItemValue = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders with the correct label', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('State')).toBeInTheDocument();
+ });
+
+ it('does not render an error message when error is null', () => {
+ const { queryByText } = render(
+ ,
+ );
+ expect(queryByText('Please select a state')).not.toBeInTheDocument();
+ });
+
+ it('renders an error message when error is not null', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Please select a state')).toBeInTheDocument();
+ });
+
+ it('calls handleChange on blur with name and selected value', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const select = getByRole('combobox');
+ fireEvent.blur(select, { target: { value: 'NY' } });
+ expect(handleChange).toHaveBeenCalledWith({ name: 'state', value: 'NY' });
+ });
+
+ it('calls setItemValue to blank the dependent select when the dependee value changes', () => {
+ const getItemWithUS = makeGetItem({ value: 'CA', error: null, dirty: false }, 'US');
+ const getItemWithUK = makeGetItem({ value: 'CA', error: null, dirty: false }, 'UK');
+
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ expect(setItemValue).toHaveBeenCalledWith({ name: 'state', value: 'ENG' });
+ });
+});
diff --git a/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Input.test.tsx b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Input.test.tsx
new file mode 100644
index 0000000000..b53fb9bfbc
--- /dev/null
+++ b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Input.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * Copyright (C) 2021-2026 Technology Matters
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://www.gnu.org/licenses/.
+ */
+
+import { render, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { FormInputType } from 'hrm-form-definitions';
+
+import InputText from '../Input';
+import { PreEngagementDataItem } from '../../../../store/definitions';
+
+jest.mock('../../../../localization/LocalizedTemplate', () => ({
+ __esModule: true,
+ default: ({ code }: { code: string }) => <>{code}>,
+}));
+
+describe('Input component', () => {
+ const definition = {
+ name: 'friendlyName',
+ type: FormInputType.Input as FormInputType.Input | FormInputType.Email,
+ label: 'Full Name',
+ placeholder: 'Enter your name',
+ };
+
+ const noError: PreEngagementDataItem = { value: '', error: null, dirty: false };
+ const withError: PreEngagementDataItem = { value: '', error: 'This field is required', dirty: true };
+
+ const getItem = (item: PreEngagementDataItem) => (_name: string) => item;
+ const handleChange = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders with the correct label', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Full Name')).toBeInTheDocument();
+ });
+
+ it('does not render an error message when error is null', () => {
+ const { queryByText } = render(
+ ,
+ );
+ expect(queryByText('This field is required')).not.toBeInTheDocument();
+ });
+
+ it('renders an error message when error is not null', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('This field is required')).toBeInTheDocument();
+ });
+
+ it('calls handleChange on blur with name and value', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+ const input = getByPlaceholderText('Enter your name');
+ fireEvent.blur(input, { target: { value: 'John' } });
+ expect(handleChange).toHaveBeenCalledWith({ name: 'friendlyName', value: 'John' });
+ });
+});
diff --git a/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Select.test.tsx b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Select.test.tsx
new file mode 100644
index 0000000000..1350393c21
--- /dev/null
+++ b/aselo-webchat-react-app/src/components/forms/formInputs/__tests__/Select.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * Copyright (C) 2021-2026 Technology Matters
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://www.gnu.org/licenses/.
+ */
+
+import { render, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { FormInputType } from 'hrm-form-definitions';
+
+import Select from '../Select';
+import { PreEngagementDataItem } from '../../../../store/definitions';
+
+jest.mock('../../../../localization/LocalizedTemplate', () => ({
+ __esModule: true,
+ default: ({ code }: { code: string }) => <>{code}>,
+}));
+
+describe('Select component', () => {
+ const definition = {
+ name: 'category',
+ type: FormInputType.Select as FormInputType.Select,
+ label: 'Category',
+ options: [
+ { value: '', label: 'Select...' },
+ { value: 'opt1', label: 'Option 1' },
+ { value: 'opt2', label: 'Option 2' },
+ ],
+ };
+
+ const noError: PreEngagementDataItem = { value: '', error: null, dirty: false };
+ const withError: PreEngagementDataItem = { value: '', error: 'Please select a category', dirty: true };
+
+ const getItem = (item: PreEngagementDataItem) => (_name: string) => item;
+ const handleChange = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders with the correct label', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Category')).toBeInTheDocument();
+ });
+
+ it('does not render an error message when error is null', () => {
+ const { queryByText } = render(
+ ,
+ );
+ expect(queryByText('Please select a category')).not.toBeInTheDocument();
+ });
+
+ it('renders an error message when error is not null', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Please select a category')).toBeInTheDocument();
+ });
+
+ it('calls handleChange on blur with name and selected value', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const select = getByRole('combobox');
+ fireEvent.blur(select, { target: { value: 'opt1' } });
+ expect(handleChange).toHaveBeenCalledWith({ name: 'category', value: 'opt1' });
+ });
+});
diff --git a/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts b/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts
index 94b9f28747..1cbfa56ed3 100644
--- a/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts
+++ b/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts
@@ -50,6 +50,10 @@ const validateEmailPattern = ({
return null;
}
+ if (!value) {
+ return null;
+ }
+
const matches = (value as string).match(EMAIL_PATTERN);
if (Boolean(matches?.length)) {