Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/components/Form/Form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,119 @@ describe('Form', () => {
expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) });
});
});

describe('record array', () => {
const renderForm = (itemSchema: z.ZodType<{ name?: string }>) => {
render(
<Form
content={{
items: {
fieldset: { name: { kind: 'string', label: 'Name', variant: 'input' } },
kind: 'record-array',
label: 'Item'
}
}}
data-testid={testid}
validationSchema={z.object({ items: z.array(itemSchema).optional() })}
onError={onError}
onSubmit={onSubmit}
/>
);
return screen.getByLabelText<HTMLInputElement>('Name');
};

afterEach(() => {
vi.clearAllMocks();
});

it('should submit an optional record array that was not filled in, although its fields are required', async () => {
renderForm(z.object({ name: z.string().min(1) }));
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.lastCall?.[0]).toStrictEqual({});
expect(onError).not.toHaveBeenCalled();
});

it('should not submit a record with all values empty in place of an omitted record array', async () => {
renderForm(z.object({ name: z.string().optional() }));
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.lastCall?.[0]).toStrictEqual({});
});

it('should omit a record array where the only value was entered and then cleared', async () => {
const name = renderForm(z.object({ name: z.string().min(1) }));
await userEvent.type(name, 'a');
await userEvent.clear(name);
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.lastCall?.[0]).toStrictEqual({});
});

it('should show the errors on the fields of the first record when the record array is required', async () => {
render(
<Form
content={{
items: {
fieldset: { name: { kind: 'string', label: 'Name', variant: 'input' } },
kind: 'record-array',
label: 'Item'
}
}}
data-testid={testid}
validationSchema={z.object({ items: z.array(z.object({ name: z.string('Required') })) })}
onError={onError}
onSubmit={onSubmit}
/>
);
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onError).toHaveBeenCalledOnce());
expect(onError.mock.lastCall?.[0].issues[0].path).toStrictEqual(['items', 0, 'name']);
expect(screen.getAllByTestId('error-message-text').map((e) => e.innerHTML)).toMatchObject(['Required']);
expect(onSubmit).not.toHaveBeenCalled();
});

it('should not show errors on an empty optional record array when a different field is invalid', async () => {
render(
<Form
content={{
items: {
fieldset: { name: { kind: 'string', label: 'Name', variant: 'input' } },
kind: 'record-array',
label: 'Item'
},
title: { kind: 'string', label: 'Title', variant: 'input' }
}}
data-testid={testid}
validationSchema={z.object({
items: z.array(z.object({ name: z.string('Name required') })).optional(),
title: z.string('Title required')
})}
onError={onError}
onSubmit={onSubmit}
/>
);
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onError).toHaveBeenCalledOnce());
expect(screen.getAllByTestId('error-message-text').map((e) => e.innerHTML)).toMatchObject(['Title required']);
});

it('should submit the records when a value is set', async () => {
const name = renderForm(z.object({ name: z.string().min(1) }));
await userEvent.type(name, 'Jane');
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.lastCall?.[0]).toStrictEqual({ items: [{ name: 'Jane' }] });
});

it('should continue to validate an empty record when another record has a value', async () => {
const name = renderForm(z.object({ name: z.string().min(1) }));
await userEvent.type(name, 'Jane');
await userEvent.click(screen.getByRole('button', { name: 'Append' }));
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onError).toHaveBeenCalledOnce());
expect(onError.mock.lastCall?.[0].issues[0].path).toStrictEqual(['items', 1, 'name']);
expect(onSubmit).not.toHaveBeenCalled();
});
});
});
24 changes: 20 additions & 4 deletions src/components/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { Heading } from '../Heading/Heading.tsx';
import { Separator } from '../Separator/Separator.tsx';
import { ErrorMessage } from './ErrorMessage.tsx';
import { FieldsComponent } from './FieldsComponent.tsx';
import { getInitialValues } from './utils.ts';
import { getInitialValues, getValuesToValidate } from './utils.ts';

import type { FormErrors } from './types.ts';

Expand Down Expand Up @@ -122,6 +122,23 @@ const Form = <TSchema extends ZodTypeLike<FormDataType>, TData extends TSchema['
subscribe.onChange(values, setValues);
}, [subscribe?.selector(values)]);

/**
* Validate the values without the record arrays that the user did not fill in. If the schema reports
* an issue for an omitted record array (i.e., it is required), validate all values instead, so that
* the errors refer to the fields of its records and are displayed to the user.
*/
const validate = async () => {
const valuesToValidate = getValuesToValidate(values);
const result = await validationSchema.safeParseAsync(valuesToValidate);
if (result.success) {
return result;
}
const isOmittedFieldRequired = result.error.issues.some(({ path: [fieldName] }) => {
return typeof fieldName === 'string' && fieldName in values && !(fieldName in valuesToValidate);
});
return isOmittedFieldRequired ? validationSchema.safeParseAsync(values) : result;
};

const reset = () => {
setRootErrors([]);
setErrors({});
Expand All @@ -132,7 +149,7 @@ const Form = <TSchema extends ZodTypeLike<FormDataType>, TData extends TSchema['

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const result = await validationSchema.safeParseAsync(values);
const result = await validate();
if (!result.success) {
console.error(result.error.issues);
handleError(result.error);
Expand Down Expand Up @@ -171,8 +188,7 @@ const Form = <TSchema extends ZodTypeLike<FormDataType>, TData extends TSchema['
const revalidate = () => {
const hasErrors = Object.keys(errors).length > 0 || rootErrors.length;
if (hasErrors) {
validationSchema
.safeParseAsync(values)
validate()
.then((result) => {
if (!result.success) {
handleError(result.error);
Expand Down
26 changes: 26 additions & 0 deletions src/components/Form/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type {
UnknownFormField
} from '@douglasneuroinformatics/libui-form-types';

function isEmptyScalarValue(value: unknown) {
return value === undefined || value === null || value === '' || (value instanceof Set && value.size === 0);
}

export function getInitialValues<T extends FormDataType>(values: PartialNullableFormDataType<T>) {
const initialValues: { [key: string]: unknown } = {};
for (const key in values) {
Expand All @@ -23,6 +27,28 @@ export function getInitialValues<T extends FormDataType>(values: PartialNullable
return initialValues as PartialFormDataType<T>;
}

/**
* Returns the values to validate. A record array where every value of every record is empty (i.e.,
* `undefined`, an empty string, or an empty set) is omitted, so the user can submit a record array
* that they did not fill in if the validation schema marks it as optional. A record array where
* at least one value is set is not changed, so the indices in validation errors continue to match the
* records displayed to the user.
*/
export function getValuesToValidate<T extends FormDataType>(values: PartialFormDataType<T>): PartialFormDataType<T> {
const result: { [key: string]: unknown } = {};
for (const key in values) {
const value: unknown = values[key];
if (
Array.isArray(value) &&
value.every((record: { [key: string]: unknown }) => Object.values(record).every(isEmptyScalarValue))
) {
continue;
}
result[key] = value;
}
return result as PartialFormDataType<T>;
}

/** Extract a flat array of form fields from the content. This function assumes there are no duplicate keys in groups */
export function getFormFields<T extends FormDataType>(content: FormContent<T>): FormFields<T> {
if (!Array.isArray(content)) {
Expand Down
Loading