Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/client/src/components/patterns/AutoForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AutoForm schema={withOptionalPassword} onSubmit={onSubmit} />)
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(
<AutoForm
Expand Down
27 changes: 20 additions & 7 deletions apps/client/src/components/patterns/AutoForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,13 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
}) {
// 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<Record<string, string | boolean | string[] | null>>(() => {
const initial = (initialValues ?? {}) as Record<string, unknown>
Expand All @@ -112,14 +116,23 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
const [errors, setErrors] = useState<Record<string, string>>({})

// `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(
Expand Down
Loading