diff --git a/apps/client/src/components/patterns/AutoForm.test.tsx b/apps/client/src/components/patterns/AutoForm.test.tsx index 19a9959b..14bd4ff9 100644 --- a/apps/client/src/components/patterns/AutoForm.test.tsx +++ b/apps/client/src/components/patterns/AutoForm.test.tsx @@ -83,6 +83,18 @@ describe('AutoForm', () => { expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['keep'] })) }) + it('submits a blank optional field as absent, not empty, so it never fails a value-only check like min-length', () => { + const onSubmit = vi.fn() + const withOptionalPassword = schema.extend({ + password: z.string().min(8, 'Password must be at least 8 characters').optional(), + }) + render() + fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } }) + fireEvent.click(screen.getByText('Save')) + expect(screen.queryByText('Password must be at least 8 characters')).not.toBeInTheDocument() + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ password: undefined })) + }) + it('seeds the form from initialValues', () => { render( >({ }) { // Derived once, up front: `schema.shape` is indexed by an open `string` key, // so re-reading it per render would fight `noUncheckedIndexedAccess`. - const fields: { key: string; kind: FieldKind }[] = Object.entries(schema.shape).map( - ([key, fieldSchema]) => ({ key, kind: fieldKind(key, fieldSchema) }), - ) + const fields: { key: string; kind: FieldKind; optional: boolean }[] = Object.entries( + schema.shape, + ).map(([key, fieldSchema]) => ({ + key, + kind: fieldKind(key, fieldSchema), + optional: fieldSchema.isOptional(), + })) const [values, setValues] = useState>(() => { const initial = (initialValues ?? {}) as Record @@ -112,14 +116,23 @@ export function AutoForm>({ const [errors, setErrors] = useState>({}) // `tags` is already held as an array by TagsInput, so nothing is parsed out of - // a raw string here — the state shape per field kind is the parsed shape. - function parsedValue(key: string): unknown { - return values[key] + // a raw string here — the state shape per field kind is the parsed shape. An + // untouched optional text field stays '' in local state; sent as-is it fails + // schema checks meant only for a value someone actually typed (e.g. password's + // min-length), so a blank optional field reports absent, not empty. + function parsedValue(key: string, kind: FieldKind, optional: boolean): unknown { + const value = values[key] + if (optional && value === '' && (kind === 'text' || kind === 'textarea' || kind === 'password')) { + return undefined + } + return value } function handleSubmit(e: FormEvent) { e.preventDefault() - const candidate = Object.fromEntries(fields.map(({ key }) => [key, parsedValue(key)])) + const candidate = Object.fromEntries( + fields.map(({ key, kind, optional }) => [key, parsedValue(key, kind, optional)]), + ) const result = schema.safeParse(candidate) if (!result.success) { setErrors(