Skip to content

Commit 16f1975

Browse files
Pari Work Tempclaude
andcommitted
Add spec: tn-forms-v4
Modular Zod + Formik toolkit to replace class-based Form/FormField/Validator API. 12 assertions on feature/v4 branch covering: - Core (framework-agnostic): createFormConfig, fieldMeta, createArrayHelpers, createAsyncValidate, createStepValidator, snapshotValues - React hooks: useFormField, useFormArray, useStepForm, useFormSubmit - Package setup with subpath exports (/core, /react) - Migration guide from v3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bccebe5 commit 16f1975

13 files changed

Lines changed: 417 additions & 0 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
id: async-validate
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 1
6+
status: not_started
7+
depends-on: create-form-config
8+
branch: feature/v4
9+
---
10+
11+
# createAsyncValidate bridges async Zod refines to Formik's validate prop
12+
13+
`createAsyncValidate(zodSchema)` returns an async function compatible with Formik's `validate` prop that supports async Zod refinements (e.g., server-side email uniqueness checks, API-based zip code validation).
14+
15+
Formik supports async validation via `validate` (function prop) but NOT via `validationSchema`. `zod-formik-adapter`'s `toFormikValidationSchema` only handles synchronous validation. This leaves a gap for schemas with `.refine(async ...)`.
16+
17+
## Success Criteria
18+
19+
- `createAsyncValidate(schemaWithAsyncRefines)` returns `async (values) => errors`
20+
- Returned function matches Formik's `validate` signature: accepts values object, returns errors object (or empty object if valid)
21+
- Errors are keyed by field name matching Zod's error path
22+
- Async refines run and their errors appear on the correct field
23+
- Sync refines in the same schema also work (mixed sync/async)
24+
- Cross-field async refines (e.g., `.refine(async (data) => ...)`) map errors to the correct `path` field
25+
- Debounce-friendly: does not introduce its own debouncing (consumers handle that)
26+
- Can be used alongside `validationSchema` for sync rules + `validate` for async rules
27+
- Tests verify: async field validation, async cross-field validation, error path mapping, mixed sync/async schemas
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
id: create-array-helpers
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 1
6+
status: not_started
7+
depends-on: create-form-config
8+
branch: feature/v4
9+
---
10+
11+
# createArrayHelpers provides typed add/remove/defaults for Formik FieldArrays
12+
13+
`createArrayHelpers(zodArrayItemSchema)` returns helpers for managing dynamic form arrays with Formik's `<FieldArray>`, replacing tn-forms v3's `FormArray` class.
14+
15+
tn-forms v3 `FormArray` provided: typed `add()` with default values from the FormClass, `remove(index)`, `groups` array, `value` extraction, and `replicate()`. Formik's `<FieldArray>` gives raw `push`/`remove` but no typed defaults for new rows.
16+
17+
## Success Criteria
18+
19+
- `createArrayHelpers(addressSchema)` returns `{ defaultItem, fieldNames }`
20+
- `defaultItem` is a fully-typed object with defaults extracted from the item schema (uses same logic as `createFormConfig`)
21+
- `fieldNames` is a typed tuple of the item schema's field names
22+
- Works with Formik's `<FieldArray>``arrayHelpers.push(defaultItem)` adds a typed row
23+
- Handles nested schemas: `z.object({ street: z.string().default(''), city: z.string().default('') })`
24+
- Handles nullable fields in array items: `z.string().nullable().default(null)`
25+
- Tests verify: creating default items, type inference on items, integration with Formik FieldArray push
26+
- Replaces tn-forms v3 patterns: `new FormArray({ FormClass, groups: [] })`, `.add()`, `.remove()`, `.groups[i].field`
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
id: create-form-config
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 1
6+
status: not_started
7+
branch: feature/v4
8+
---
9+
10+
# createFormConfig returns initialValues, validationSchema, and fieldNames from a Zod schema
11+
12+
`createFormConfig(zodSchema)` accepts any `z.ZodObject` or `z.ZodEffects` (refined object) and returns:
13+
14+
- `initialValues` — extracted from `.default()` values on each field. Handles `ZodDefault`, `ZodOptional`, `ZodNullable`, `ZodArray`, `ZodBoolean`, `ZodNumber`, `ZodUnion`, and `ZodEffects` (refinements). Does NOT call `schema.parse({})` (which throws on required fields).
15+
- `validationSchema` — Formik-compatible validation schema via `zod-formik-adapter`'s `toFormikValidationSchema`
16+
- `fieldNames` — typed tuple of all top-level field names from the schema shape
17+
18+
## Success Criteria
19+
20+
- `createFormConfig(z.object({ name: z.string().default(''), age: z.number().default(0) }))` returns `{ initialValues: { name: '', age: 0 }, validationSchema: <FormikSchema>, fieldNames: ['name', 'age'] }`
21+
- Handles `z.string().nullable().default(null)``null`
22+
- Handles `z.array(z.string()).default([])``[]`
23+
- Handles `z.boolean().default(false)``false`
24+
- Handles schemas with `.refine()` — extracts defaults from the inner `z.ZodObject`
25+
- `fieldNames` is typed as `readonly` tuple of literal string keys, not `string[]`
26+
- Exported from package index
27+
- Tests cover all Zod type permutations from tn-forms v3 test suite (string, number, boolean, date, null, arrays, nested objects)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
id: field-meta
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 2
6+
status: not_started
7+
branch: feature/v4
8+
---
9+
10+
# fieldMeta co-locates UI metadata with Zod schema fields
11+
12+
`fieldMeta(zodSchema, metaMap)` associates UI metadata (label, placeholder, type, icon) with schema field names in a type-safe way.
13+
14+
Zod schemas carry validation but no UI information. tn-forms v3 stored `label`, `placeholder`, and `type` on each `FormField`. This utility preserves that pattern without coupling UI concerns into the validation schema.
15+
16+
## Success Criteria
17+
18+
- `fieldMeta(schema, { name: { label: 'Full Name', placeholder: 'Enter name' } })` returns a typed metadata object
19+
- TypeScript errors if a key in `metaMap` doesn't exist in the schema shape
20+
- TypeScript errors if metadata fields don't match the `FieldMeta` interface (`{ label?: string, placeholder?: string, type?: string }` — extensible via generic)
21+
- Metadata object is indexable by field name: `meta.name.label`
22+
- Works with `z.ZodObject` and `z.ZodEffects` (refined schemas)
23+
- Does NOT modify or wrap the Zod schema — it's a parallel data structure
24+
- Exported from package index
25+
- Tests verify type safety and runtime access
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
id: migration-guide
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 2
6+
status: not_started
7+
depends-on: package-setup
8+
branch: feature/v4
9+
---
10+
11+
# Migration guide documents path from tn-forms v3 to v4
12+
13+
A migration guide exists that maps every tn-forms v3 pattern to its v4 equivalent, with before/after code examples. Published as the package README.
14+
15+
## Success Criteria
16+
17+
- README.md contains a migration guide section with before/after examples for:
18+
- `new FormField({ validators: [...] })` → Zod schema field with `.min()`, `.email()`, etc.
19+
- `new Form(values) as TUserForm``createFormConfig(schema)` + Formik `<Formik>`
20+
- `form.value` → Formik `values`
21+
- `form.isValid` / `field.isValid` → Formik `isValid` / `!errors.fieldName`
22+
- `form.validate()` → Formik `validateForm()`
23+
- `FormArray` add/remove → `createArrayHelpers` + Formik `<FieldArray>`
24+
- `FormLevelValidator` / `MustMatchValidator` → Zod `.refine()` on object
25+
- `form.replicate()``snapshotForm` / `restoreForm`
26+
- Field metadata (label, placeholder) → `fieldMeta()`
27+
- `isRequired: false` pattern → Zod `.optional()` or conditional `.refine()`
28+
- Each example shows the v3 code and the v4 equivalent side by side
29+
- Guide mentions breaking changes (removed classes, peer deps required)
30+
- Guide includes a "Quick Start" section for new projects (not migrating)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
id: package-setup
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 1
6+
status: not_started
7+
branch: feature/v4
8+
---
9+
10+
# Package ships submodule architecture with separate entry points for core and react
11+
12+
The `@thinknimble/tn-forms` package (v4) uses package.json `exports` field for subpath imports. Core utilities (Zod-only) are importable without React. React/Formik hooks are a separate entry point with their own peer deps.
13+
14+
## Success Criteria
15+
16+
- `package.json` version is `4.0.0-beta.1`
17+
- `package.json` `exports` field defines three entry points:
18+
- `"."``dist/index.js` (re-exports core + react)
19+
- `"./core"``dist/core/index.js` (Zod utilities only)
20+
- `"./react"``dist/react/index.js` (React + Formik hooks)
21+
- Source structure:
22+
- `src/core/` — all framework-agnostic utilities
23+
- `src/react/` — all React/Formik hooks
24+
- `src/index.ts` — barrel re-export of both
25+
- Peer dependencies:
26+
- `zod` — required by core
27+
- `formik`, `react`, `zod-formik-adapter` — required by react, marked as optional peers for core-only consumers
28+
- `peerDependenciesMeta` marks `formik`, `react`, and `zod-formik-adapter` as `optional: true`
29+
- Old v3 source files (`src/forms.ts`, `src/validators.ts`, `src/interfaces.ts`, `src/types.ts`, `src/utils.ts`, `src/custom-types/`) are removed
30+
- `email-validator`, `luxon`, `libphonenumber-js`, `@thinknimble/tn-utils`, `babel-loader`, `install` are removed from dependencies
31+
- `tsup` config builds all three entry points with correct output paths and declaration files
32+
- `import { createFormConfig } from '@thinknimble/tn-forms/core'` works without React installed
33+
- `import { useFormField } from '@thinknimble/tn-forms/react'` works when React + Formik are installed
34+
- `import { createFormConfig, useFormField } from '@thinknimble/tn-forms'` works as barrel import
35+
- TypeScript builds cleanly, all entry points have `.d.ts` files
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
id: snapshot-restore
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 2
6+
status: not_started
7+
branch: feature/v4
8+
---
9+
10+
# snapshotValues and restoreValues deep copy plain form value objects
11+
12+
`snapshotValues(values)` creates a deep copy of a plain values object. `restoreValues` is a type-safe identity that ensures the snapshot shape matches. These are framework-agnostic utilities in `/core` — the React layer (`useFormField`, etc.) can use them internally, and consumers can use them for edit/cancel patterns with any framework.
13+
14+
Replaces tn-forms v3's `form.replicate()`. Framework-specific restore (e.g., calling `formik.setValues(snapshot)`) is the consumer's responsibility — this utility only handles the deep copy.
15+
16+
## Success Criteria
17+
18+
- `snapshotValues(values)` returns a deep copy — no shared references with the original
19+
- Works with nested objects, arrays, null values, Date objects
20+
- Typed: `snapshotValues<T>(values: T): T` preserves the input type
21+
- Mutating the original after snapshot does NOT affect the snapshot
22+
- Mutating the snapshot does NOT affect the original
23+
- Exported from `@thinknimble/tn-forms/core`
24+
- No React or Formik dependency
25+
- Tests verify: deep copy independence, nested objects, arrays, null handling, Date preservation, no shared references
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
id: step-validator
3+
parent: tn-forms-v4
4+
created: 2026-03-07T20:00:00Z
5+
priority: 1
6+
status: not_started
7+
depends-on: create-form-config
8+
branch: feature/v4
9+
---
10+
11+
# createStepValidator checks validity of a field subset for multi-step forms
12+
13+
`createStepValidator(zodSchema)` returns a function `isStepValid(values, errors, fieldNames)` that checks whether the specified fields have values and no validation errors. This is a framework-agnostic utility in `/core` — it works with plain objects, not Formik-specific state.
14+
15+
The React hook `useStepForm` wraps this internally, but it's also usable with Vue, Svelte, or any other framework that has a values object and an errors object.
16+
17+
## Success Criteria
18+
19+
- `createStepValidator(schema)` returns `{ isStepValid, getStepErrors }`
20+
- `isStepValid(values, errors, ['firstName', 'lastName'])` returns `true` only if:
21+
- No errors exist for those fields in the errors object
22+
- Each field has a non-empty value (not `null`, `undefined`, `''`, or whitespace-only string)
23+
- `getStepErrors(errors, ['firstName', 'lastName'])` returns only the errors for the specified fields
24+
- Handles boolean fields: `false` is treated as a valid value (not empty)
25+
- Handles number fields: `0` is treated as a valid value (not empty)
26+
- Handles array fields: `[]` is treated as empty, `['item']` is non-empty
27+
- Works with nested field names (dot notation): `isStepValid(values, errors, ['address.street'])`
28+
- Type-safe: field names are constrained to keys of `z.infer<typeof schema>`
29+
- Exported from `@thinknimble/tn-forms/core`
30+
- No React or Formik dependency
31+
- Tests verify: valid step, invalid step (errors), invalid step (empty values), nested fields, edge cases (boolean false, number 0, empty arrays)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
id: use-form-array
3+
parent: tn-forms-v4
4+
created: 2026-03-08T00:00:00Z
5+
priority: 1
6+
status: not_started
7+
depends-on: create-array-helpers
8+
branch: feature/v4
9+
---
10+
11+
# useFormArray provides typed array management with defaults for Formik FieldArrays
12+
13+
`useFormArray(name, itemSchema)` wraps Formik's `<FieldArray>` logic into a hook that provides typed `addRow()` (with defaults from the Zod item schema), `removeRow(index)`, and access to the current rows — replacing tn-forms v3's `FormArray` class.
14+
15+
## Success Criteria
16+
17+
- `useFormArray('addresses', addressItemSchema)` returns `{ rows, addRow, removeRow, fieldNames }`
18+
- `rows` — the current array values, typed as `z.infer<typeof addressItemSchema>[]`
19+
- `addRow()` — pushes a new item with defaults extracted from `addressItemSchema` (uses `createArrayHelpers` internally)
20+
- `addRow(overrides)` — pushes a new item with defaults merged with provided overrides
21+
- `removeRow(index)` — removes the item at the given index
22+
- `fieldNames` — typed tuple of the item schema's field names
23+
- Each row is accessible for per-field wiring: `rows[i]` gives the values, errors accessed via `errors.addresses[i].city`
24+
- Adding a row does NOT trigger validation on existing rows
25+
- Removing a row updates indices correctly
26+
- Works with nested schemas (item schema can have nested objects/arrays)
27+
- Hook must be called within a `<Formik>` context
28+
- Exported from `@thinknimble/tn-forms/react`
29+
- Tests verify: add with defaults, add with overrides, remove, type inference, no validation side effects on add
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
id: use-form-field
3+
parent: tn-forms-v4
4+
created: 2026-03-08T00:00:00Z
5+
priority: 1
6+
status: not_started
7+
depends-on: field-meta
8+
branch: feature/v4
9+
---
10+
11+
# useFormField combines Formik's useField with fieldMeta into a single hook
12+
13+
`useFormField(name, meta?)` wraps Formik's `useField()` and merges in field metadata (label, placeholder, type) so components get everything they need from one call. Replaces the pattern of manually wiring `useField()` + error display + metadata lookup.
14+
15+
## Success Criteria
16+
17+
- `useFormField('email')` returns `{ field, meta, helpers, error, touched, label, placeholder, type }`
18+
- `field` — Formik's field props (`value`, `onChange`, `onBlur`, `name`)
19+
- `meta` — Formik's meta (`error`, `touched`, `initialValue`)
20+
- `helpers` — Formik's helpers (`setValue`, `setTouched`, `setError`)
21+
- `error` — shorthand: `meta.touched && meta.error` (only shows after interaction)
22+
- `label`, `placeholder`, `type` — from fieldMeta if provided to a parent `<FormMetaProvider>`
23+
- `<FormMetaProvider meta={fieldMetaObject}>` context provider makes metadata available to all `useFormField` calls within its tree
24+
- `useFormField('email', { label: 'Email', placeholder: 'you@example.com' })` accepts inline override that takes precedence over provider
25+
- Returns are fully typed based on the form values type
26+
- Hook must be called within a `<Formik>` context (throws clear error if not)
27+
- Exported from `@thinknimble/tn-forms/react`
28+
- Tests verify: basic field wiring, metadata from provider, inline meta override, error display after touch, type safety

0 commit comments

Comments
 (0)