Skip to content
Draft
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
27 changes: 25 additions & 2 deletions aselo-webchat-react-app/src/components/PreEngagementFormPhase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ import { FormEvent } from 'react';
import { Button } from '@twilio-paste/core/button';
import { useDispatch, useSelector } from 'react-redux';
import { Text } from '@twilio-paste/core/text';
import { FormInputType } from 'hrm-form-definitions';

import { submitAndInitChatThunk, updatePreEngagementDataField } from '../store/actions/genericActions';
import {
submitAndInitChatThunk,
updatePreEngagementDataField,
updatePreEngagementDataFields,
} from '../store/actions/genericActions';
import { AppState } from '../store/definitions';
import { Header } from './Header';
import { NotificationBar } from './NotificationBar';
Expand All @@ -39,8 +44,26 @@ export const PreEngagementFormPhase = () => {
};
const handleChange = setItemValue;

const handleSubmit = async (e: FormEvent) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;

// Collect current DOM values for all form fields and sync them to Redux in a
// single dispatch before validation runs. This ensures fields that have been
// filled but not yet blurred are still captured.
const domFieldValues = (preEngagementFormDefinition?.fields ?? []).reduce<
{ name: string; value: string | boolean }[]
>((accum, field) => {
const element = form.querySelector<HTMLInputElement | HTMLSelectElement>(`#${field.name}`);
if (!element) return accum;
const value = field.type === FormInputType.Checkbox ? (element as HTMLInputElement).checked : element.value;
return [...accum, { name: field.name, value }];
}, []);

if (domFieldValues.length > 0) {
dispatch(updatePreEngagementDataFields(domFieldValues) as any);
}

await dispatch(submitAndInitChatThunk() as any);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,31 @@ describe('Pre Engagement Form Phase - validation', () => {
jest.spyOn(initAction, 'initSession').mockImplementation((data: any) => data);
});

it('Input: form is valid when a "required" input has a DOM value but has not been blurred', async () => {
const namePlaceholder = 'Enter name';
const store = createValidationStore([
{
name: 'name',
type: FormInputType.Input,
label: 'Name',
required: true,
placeholder: namePlaceholder,
} as PreEngagementFormItem,
]);
const { container, getByPlaceholderText } = render(
<Provider store={store}>
<PreEngagementFormPhase />
</Provider>,
);
const nameInput = getByPlaceholderText(namePlaceholder);
// Fill the input but do NOT blur — Redux has no value yet
fireEvent.change(nameInput, { target: { value: 'John' } });
await submitForm(container);
// DOM sync on submit should have pushed the value into Redux before validation
expect(sessionDataHandler.fetchAndStoreNewSession).toHaveBeenCalledWith({ formData: { name: 'John' } });
expect(initAction.initSession).toHaveBeenCalled();
});

it('Input: form is not valid when a "required" input is empty', async () => {
const store = createValidationStore([
{ name: 'name', type: FormInputType.Input, label: 'Name', required: true } as PreEngagementFormItem,
Expand All @@ -246,15 +271,23 @@ describe('Pre Engagement Form Phase - validation', () => {
});

it('Input: form is valid when a "required" input has a value', async () => {
const store = createValidationStore(
[{ name: 'name', type: FormInputType.Input, label: 'Name', required: true } as PreEngagementFormItem],
{ name: { value: 'John', error: null, dirty: true } },
);
const { container } = render(
const namePlaceholder = 'Enter name';
const store = createValidationStore([
{
name: 'name',
type: FormInputType.Input,
label: 'Name',
required: true,
placeholder: namePlaceholder,
} as PreEngagementFormItem,
]);
const { container, getByPlaceholderText } = render(
<Provider store={store}>
<PreEngagementFormPhase />
</Provider>,
);
// Fill the input but do NOT blur — DOM sync on submit will capture the value
fireEvent.change(getByPlaceholderText(namePlaceholder), { target: { value: 'John' } });
await submitForm(container);
expect(sessionDataHandler.fetchAndStoreNewSession).toHaveBeenCalled();
expect(initAction.initSession).toHaveBeenCalled();
Expand All @@ -276,15 +309,23 @@ describe('Pre Engagement Form Phase - validation', () => {
});

it('Email: form is valid when a "required" email input has a valid email', async () => {
const store = createValidationStore(
[{ name: 'email', type: FormInputType.Email, label: 'Email', required: true } as PreEngagementFormItem],
{ email: { value: 'test@test.com', error: null, dirty: true } },
);
const { container } = render(
const emailPlaceholder = 'Enter email';
const store = createValidationStore([
{
name: 'email',
type: FormInputType.Email,
label: 'Email',
required: true,
placeholder: emailPlaceholder,
} as PreEngagementFormItem,
]);
const { container, getByPlaceholderText } = render(
<Provider store={store}>
<PreEngagementFormPhase />
</Provider>,
);
// Fill the input but do NOT blur — DOM sync on submit will capture the value
fireEvent.change(getByPlaceholderText(emailPlaceholder), { target: { value: 'test@test.com' } });
await submitForm(container);
expect(sessionDataHandler.fetchAndStoreNewSession).toHaveBeenCalled();
expect(initAction.initSession).toHaveBeenCalled();
Expand Down Expand Up @@ -336,7 +377,11 @@ describe('Pre Engagement Form Phase - validation', () => {
type: FormInputType.Select,
label: 'Choice',
required: true,
options: [{ value: 'opt1', label: 'Option 1' }],
// Empty placeholder option ensures the select defaults to an empty value in the DOM
options: [
{ value: '', label: 'Please select...' },
{ value: 'opt1', label: 'Option 1' },
],
} as PreEngagementFormItem,
]);
const { container } = render(
Expand Down Expand Up @@ -457,22 +502,22 @@ describe('Pre Engagement Form Phase - validation', () => {
});

it('Checkbox: form is valid when a "required" checkbox is checked', async () => {
const store = createValidationStore(
[
{
name: 'agree',
type: FormInputType.Checkbox,
label: 'I agree',
required: { value: true, message: 'RequiredFieldError' },
} as PreEngagementFormItem,
],
{ agree: { value: true, error: null, dirty: true } },
);
const store = createValidationStore([
{
name: 'agree',
type: FormInputType.Checkbox,
label: 'I agree',
required: { value: true, message: 'RequiredFieldError' },
} as PreEngagementFormItem,
]);
const { container } = render(
<Provider store={store}>
<PreEngagementFormPhase />
</Provider>,
);
// Check the checkbox in the DOM but do NOT blur — DOM sync on submit will capture the state
const checkbox = container.querySelector('#agree') as HTMLInputElement;
fireEvent.click(checkbox);
await submitForm(container);
expect(sessionDataHandler.fetchAndStoreNewSession).toHaveBeenCalled();
expect(initAction.initSession).toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const Checkbox: React.FC<Props> = ({ definition, getItem, handleChange, defaultV

return (
<Box style={{ marginBottom: '20px' }}>
<Label htmlFor={name}>
<Label htmlFor={name} data-testid={`${name}-label`}>
<CheckboxInput
id={name}
hasError={Boolean(error)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
removeNotification,
submitAndInitChatThunk,
updatePreEngagementData,
updatePreEngagementDataFields,
} from '../genericActions';
import { EngagementPhase, Notification } from '../../definitions';
import {
Expand Down Expand Up @@ -165,6 +166,57 @@ describe('Actions', () => {
});
});

describe('updatePreEngagementDataFields', () => {
const formFields = [
{ name: 'firstName', type: FormInputType.Input, label: 'First name' },
{ name: 'email', type: FormInputType.Email, label: 'Email' },
];

const emptyPreEngagementData = {};
const getState = jest.fn();

beforeEach(() => {
jest.resetAllMocks();
getState.mockReturnValue({
config: { preEngagementFormDefinition: { fields: formFields } },
session: { preEngagementData: emptyPreEngagementData },
});
});

it('dispatches a single ACTION_UPDATE_PRE_ENGAGEMENT_DATA action with all fields updated', () => {
const dispatch = jest.fn();
const fieldsToUpdate = [
{ name: 'firstName', value: 'Alice' },
{ name: 'email', value: 'alice@example.com' },
];
updatePreEngagementDataFields(fieldsToUpdate)(dispatch as any, getState as any, undefined);

expect(dispatch).toHaveBeenCalledTimes(1);
const dispatched = dispatch.mock.calls[0][0];
expect(dispatched.type).toBe('ACTION_UPDATE_PRE_ENGAGEMENT_DATA');
expect(dispatched.payload.firstName.value).toBe('Alice');
expect(dispatched.payload.email.value).toBe('alice@example.com');
});

it('merges updated fields with existing pre-engagement data', () => {
getState.mockReturnValue({
config: { preEngagementFormDefinition: { fields: formFields } },
session: { preEngagementData: { firstName: { value: 'Old', error: null, dirty: true } } },
});
const dispatch = jest.fn();
updatePreEngagementDataFields([{ name: 'email', value: 'new@example.com' }])(
dispatch as any,
getState as any,
undefined,
);

expect(dispatch).toHaveBeenCalledTimes(1);
const dispatched = dispatch.mock.calls[0][0];
expect(dispatched.payload.firstName.value).toBe('Old');
expect(dispatched.payload.email.value).toBe('new@example.com');
});
});

describe('submitAndInitChatThunk', () => {
const token = 'token';
const conversationSid = 'sid';
Expand Down
20 changes: 20 additions & 0 deletions aselo-webchat-react-app/src/store/actions/genericActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ export const updatePreEngagementDataField = ({
};
};

export const updatePreEngagementDataFields = (
fields: { name: string; value: PreEngagementDataItem['value'] }[],
): ThunkAction<void, AppState, unknown, AnyAction> => {
return (dispatch, getState) => {
const state = getState();
const formFields = state.config.preEngagementFormDefinition?.fields ?? [];

const updatedData = fields.reduce<PreEngagementData>((accum, { name, value }) => {
const definition = formFields.find(fd => fd.name === name);
const updatedItem = updateDataItem({ definition: definition as PreEngagementFormItem, value });
return { ...accum, [name]: updatedItem };
}, state.session.preEngagementData);

dispatch({
type: ACTION_UPDATE_PRE_ENGAGEMENT_DATA,
payload: updatedData,
});
};
};

const newInitialItem = (definition: PreEngagementFormItem): PreEngagementDataItem => ({
error: null,
dirty: false,
Expand Down
Loading
Loading