From c7635a24a1af3f5000144e3990ec599bfec3359e Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Mon, 9 Nov 2026 23:21:53 -0500 Subject: [PATCH 1/2] fix(Form): omit a record array from validation when all its records are empty A record array field adds one empty record when it is rendered. If the user did not fill it in, validation failed on the required fields of that record, or the form submitted a record with no values. Closes #40 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01W53iUP9ECVtoteopM7QtC7 --- src/components/Form/Form.test.tsx | 67 +++++++++++++++++++++++++++++++ src/components/Form/Form.tsx | 6 +-- src/components/Form/utils.ts | 26 ++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index be37550..3ec94bc 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -399,4 +399,71 @@ 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 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..bc4ea7d 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'; @@ -132,7 +132,7 @@ const Form = , TData extends TSchema[' const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - const result = await validationSchema.safeParseAsync(values); + const result = await validationSchema.safeParseAsync(getValuesToValidate(values)); if (!result.success) { console.error(result.error.issues); handleError(result.error); @@ -172,7 +172,7 @@ const Form = , TData extends TSchema[' const hasErrors = Object.keys(errors).length > 0 || rootErrors.length; if (hasErrors) { validationSchema - .safeParseAsync(values) + .safeParseAsync(getValuesToValidate(values)) .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)) { From 3080177cb7fffb0166285fb79cc4bb731a3261fa Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Mon, 9 Nov 2026 23:23:41 -0500 Subject: [PATCH 2/2] fix(Form): show item errors for a required record array that is empty Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01W53iUP9ECVtoteopM7QtC7 --- src/components/Form/Form.test.tsx | 48 +++++++++++++++++++++++++++++++ src/components/Form/Form.tsx | 22 ++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index 3ec94bc..52fc5c7 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -448,6 +448,54 @@ describe('Form', () => { 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'); diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index bc4ea7d..268ec2a 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -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(getValuesToValidate(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(getValuesToValidate(values)) + validate() .then((result) => { if (!result.success) { handleError(result.error);