diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index be37550..52fc5c7 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -399,4 +399,119 @@ describe('Form', () => { expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) }); }); }); + + describe('record array', () => { + const renderForm = (itemSchema: z.ZodType<{ name?: string }>) => { + render( +
+ ); + return screen.getByLabelText('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( + + ); + 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( + + ); + 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(); + }); + }); }); diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index ae2472d..268ec2a 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -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'; @@ -122,6 +122,23 @@ const Form = , 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({}); @@ -132,7 +149,7 @@ const Form = , TData extends TSchema[' const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - const result = await validationSchema.safeParseAsync(values); + const result = await validate(); if (!result.success) { console.error(result.error.issues); handleError(result.error); @@ -171,8 +188,7 @@ const Form = , 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); diff --git a/src/components/Form/utils.ts b/src/components/Form/utils.ts index c1a80f0..0d0928a 100644 --- a/src/components/Form/utils.ts +++ b/src/components/Form/utils.ts @@ -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(values: PartialNullableFormDataType) { const initialValues: { [key: string]: unknown } = {}; for (const key in values) { @@ -23,6 +27,28 @@ export function getInitialValues(values: PartialNullable return initialValues as PartialFormDataType; } +/** + * 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(values: PartialFormDataType): PartialFormDataType { + 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; +} + /** Extract a flat array of form fields from the content. This function assumes there are no duplicate keys in groups */ export function getFormFields(content: FormContent): FormFields { if (!Array.isArray(content)) {