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)) {