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(