Skip to content

feat: form state hooks, schema adapters, wizard, DevTools + CI hardening - #31

Merged
vannt-dev merged 20 commits into
developfrom
feature/form-state-hooks-and-advanced-enhancements
Aug 5, 2026
Merged

feat: form state hooks, schema adapters, wizard, DevTools + CI hardening#31
vannt-dev merged 20 commits into
developfrom
feature/form-state-hooks-and-advanced-enhancements

Conversation

@vannt-dev

@vannt-dev vannt-dev commented Aug 5, 2026

Copy link
Copy Markdown
Owner

What

Completes the enterprise feature branch and hardens the release pipeline.

Bug fixes in the feature work

  • zodValidator returned a Promise for every real Zod schema (it checked safeParseAsync first, and Zod exposes both). validateField treats a Promise as "no errors" and useDynamicForm only calls the synchronous validateFields — so a Zod-validated form silently reported valid for invalid data. Adapters now parse synchronously, falling back to async only when the schema genuinely requires it.
  • yupValidator surfaced Yup's internal "Validation test of type ... returned a Promise" to users as a form error.
  • Replaced the value !== undefined ? value : data payload guess with an explicit { target: 'form' | 'field' } option, applied identically by all three adapters.
  • switch had a shipped renderer in react and vue but no FieldTypeMap entry, so type: 'switch' did not typecheck.
  • React useDynamicForm was missing isSubmitting/isSubmitted; Angular's handleSubmit executed immediately instead of returning a handler and never called preventDefault.

Coverage

Every package was below its floor — a hard CI gate. Core had regressed from 88/85/100/88 on develop to 68/63/79/68. Now core 89.95/85.04/96.96/90.33, react 97.34/86.95/95.65/97.34, vue 98.87/87.01/94.02/98.87, angular 91.30/75.56/92.40/91.22. Suite 249 → 394 tests.

Pipeline

  • Release was ungated: it triggered on push to master, did not depend on CI, and skipped lint, format, coverage floors and the verify scripts. On 2026-08-04 CI succeeded on master at 16:13:10 while Release failed at 16:13:09 on the same commit — publishing while CI was red was equally possible. All gates now live in a reusable quality-gates.yml that both CI and Release call.
  • Example apps were not workspaces and appeared in no workflow. Both bugs above were sitting in an example that had never been compiled. They now build in CI.
  • Added a changeset check (the three feature commits had none), a production npm audit step, CODEOWNERS (master enables require_code_owner_reviews, which did nothing without the file), and a PR template.
  • Pinned every job to .nvmrc; workflows said Node 22 while .nvmrc said 24.
  • Angular shipped src/ and test/ to npm — 61 files vs react's 8. Now files: ["dist"], 33 files.

Why

The feature branch could not merge: all four test legs failed their coverage gate, and the headline schema-adapter feature did not work with the hooks shipped alongside it.

How to test

npm run lint && npm run format-check && npm run typecheck
npm run test:types --workspace=@dynamic-field-kit/core
for p in core react vue angular; do npm run test --workspace=@dynamic-field-kit/$p -- --coverage; done
npm run test --workspace=@dynamic-field-kit/smoke
cd example/react-app && npm ci && npm run build

  • Added a changeset
  • Tests cover the change — every bug fix has a test that failed first
  • Public API changes reflected in the README
  • Behaviour consistent across react, vue and angular

Follow-up, not in this PR: useDynamicForm's handleBlur/touched cannot be wired into MultiFieldInput, which manages blur internally and exposes no hook. The example was passing a non-existent onBlurField prop; I removed it rather than expand the public API here.


Added after the initial description: one-click releases

Releases were half-manual — #25 edited versions by hand, #27 then deleted consumed changeset files by hand. Release is now workflow_dispatch only: pick bump (patch/minor/major), optionally which packages, a CHANGELOG message, and dry_run. scripts/create-changeset.js turns those inputs into a real changeset, then the run does gates → changeset version → lockfile sync → build → commit → changeset publish. No version is edited by hand and packages stay independently versioned.

The push: master trigger is removed — it opened a competing "version packages" PR, which is the same overlap that caused the manual cleanup in #27.

Verified locally against this branch's real changeset: core 1.3.0 → 1.4.0, react/vue/angular 1.4.0 → 1.5.0, internal deps rewritten, CHANGELOGs generated (then reverted).

Run it on develop, not master — required status checks apply to direct pushes, so the Actions bot cannot push the release commit to master. Versions reach master through the usual develop → master PR.

Why:
zodValidator checked safeParseAsync before safeParse. Real Zod schemas expose
both, so the async branch always won and the validator returned a Promise.
validateField treats a Promise as "no errors", and useDynamicForm only ever
calls the synchronous validateFields - so a Zod-validated form silently
reported valid for invalid data.

yupValidator had a related bug: Yup throws a plain Error (not a
ValidationError) when a test is async, and that internal message
"Validation test of type ... returned a Promise" was surfaced to the user as
a form error.

The payload rule was also a guess - `value !== undefined ? value : data` -
which picked the field value for a form-level schema, and differed from
standardSchemaValidator.

What:
- Prefer sync parsing (safeParse / validateSync), falling back to the async
  path only when the schema genuinely requires it.
- Distinguish a Yup ValidationError from its async-test error, and retry
  asynchronously instead of leaking the internal message.
- Replace the payload heuristic with an explicit SchemaValidatorOptions
  `{ field, target: 'form' | 'field' }`, applied identically by all three
  adapters. The `zodValidator(schema, 'email')` shorthand still works.
- Add zod and yup as core devDependencies; the adapters were previously only
  exercised against hand-rolled fakes that hid both bugs.

How to test:
npm run test --workspace=@dynamic-field-kit/core
Why:
The three form-state APIs had drifted. React was missing isSubmitting and
isSubmitted, which Vue and Angular both exposed, so a React consumer had no
way to disable a submit button while a submission was in flight. Angular's
handleSubmit executed immediately instead of returning a handler like React
and Vue, and never called preventDefault - so it could not be bound to a
native form submit at all.

What:
- React: add isSubmitting / isSubmitted, set around handleSubmit in a
  try/finally so a throwing onValid still clears the flag, and reset both.
- Angular: handleSubmit(onValid, onInvalid) now returns an async handler
  that calls preventDefault, matching React and Vue.

These APIs are unreleased (published angular is 1.4.0 without the store), so
no published consumer is affected.

How to test:
npm run test --workspace=@dynamic-field-kit/react
npm run test --workspace=@dynamic-field-kit/angular
Why:
The new feature work landed largely untested and pushed every package below
its coverage floor, which is a hard CI gate: core 68/63/79/68, react
functions 77.7, vue 84/74/72/84, angular 83/71/85/83. On develop core sat at
88/85/100/88, so this was a regression introduced by the feature branch.

What:
Tests for the code that shipped without any - group array helpers
(move/swap/insert plus focusFirstInvalidField under jsdom), wizard index
clamping and the empty-steps case, the DevTools overlay across all four tabs
in all three frameworks, and the extended HTML5 renderers (date/time/
datetime-local/switch/file, option id/name fallbacks, range).

Coverage is now core 89.95/85.04/96.96/90.33, react 97.34/86.95/95.65/97.34,
vue 98.87/87.01/94.02/98.87, angular 91.30/75.56/92.40/91.22 - all above
their floors. Suite grew from 249 to 394 tests.

How to test:
npm run test --workspace=@dynamic-field-kit/<pkg> -- --coverage
Why:
The three feature commits on this branch added public API to all four
packages without a changeset, so the work would have been merged and never
released. The README also promised "Integrated zodValidator" without saying
that adapters parse synchronously, which is the difference between a form
that validates and one that silently passes.

What:
- Add a minor changeset covering core, react, vue and angular.
- Document the schema adapter contract: form vs field target, the field-name
  shorthand, and when a schema forces validateFieldsAsync.
- Note that all three frameworks share one hook surface.

How to test:
npx changeset status
Why:
Both the react and vue defaultRenderersMap register a `switch` renderer, but
FieldTypeMap had no `switch` entry - so `type: 'switch'` failed to typecheck
and the shipped renderer was unreachable from TypeScript. The example app hit
exactly this: `Type '"switch"' is not assignable to type 'FieldTypeKey'`.

What:
Add `switch: boolean` to FieldTypeMap, and a type test asserting every key the
default renderer maps register is a usable field type, so the two cannot drift
apart again.

How to test:
npm run test:types --workspace=@dynamic-field-kit/core
Why:
Two problems that CI cannot currently see, because the example apps are not
workspaces and are not referenced by any workflow, and because the angular
package has no `files` field.

The example passed `onBlurField` to MultiFieldInput, which has no such prop -
it manages blur internally. The page therefore did not compile.

The angular tarball shipped src/ and test/ alongside dist/ - 61 files where
react ships 8.

What:
- example: remove the `onBlurField` prop so the page compiles.
- angular: add `files: ["dist"]`. Tarball drops 61 -> 33 files, dist/ only.

Note: useDynamicForm's handleBlur/touched cannot currently be wired into
MultiFieldInput at all. Worth a follow-up on whether the component should
expose a blur hook.

How to test:
cd example/react-app && npx next build
cd packages/angular && npm pack --dry-run
…not see

Why:
Publishing was effectively ungated. Release triggered on push to master, did
not depend on CI, and ran a thinner set of checks - no lint, no format, no
coverage floors, no verify scripts. The two workflows are independent, as the
2026-08-04 history shows: CI succeeded on master at 16:13:10 while Release
failed at 16:13:09 on the same commit. The reverse - publishing while CI is
red - was equally possible.

Three more blind spots:
- The example apps are not workspaces and were referenced by no workflow, so
  nothing compiled them. Both bugs fixed in the previous two commits (a
  missing FieldTypeMap entry and a non-existent prop) were sitting in an
  example that had never been built in CI.
- No check that a PR touching a package adds a changeset. The three feature
  commits on this branch had none.
- No dependency audit. postcss reached production deps through vue.

What:
- Extract every gate into a reusable quality-gates.yml (workflow_call). ci.yml
  and release.yml both call it, so the two can no longer drift and release
  blocks on `needs: [gates]`.
- Add an examples job building all three demo apps against the built dist.
- Add a changeset job on pull_request, and an npm audit step for production
  dependencies.
- Pin every job to .nvmrc via node-version-file. The workflows hardcoded Node
  22 while .nvmrc said 24.
- Add CODEOWNERS - master has require_code_owner_reviews enabled, which does
  nothing without this file - and a PR template.
- Widen lint-staged globs to cover .cjs/.mjs/.yaml so pre-commit stops letting
  through files that `prettier --check .` then fails on in CI.
- Configure commit.template from `prepare`; the template file existed but was
  never wired up.
- Override postcss to ^8.5.25. `npm audit fix` cannot resolve it here because
  of the known ng-packagr peer conflict. Production audit is now clean, so the
  new audit step passes.
- changesets baseBranch master -> develop, matching where PRs actually land.

How to test:
npm run lint && npm run format-check && npm run typecheck
npm audit --omit=dev --audit-level=high
npx changeset status --since=origin/develop
cd example/<app> && npm ci && npm run build
Why:
The override broke `npm ci` on Linux: "Missing: yaml@2.9.0 from lock file".
A bare `postcss` key rewrites every postcss in the tree, including
@angular-devkit/build-angular's nested copy, whose subtree resolves
differently on Linux than on Windows. npm then had to re-resolve packages the
lockfile had no entries for, so every CI job failed at install.

The override was also unnecessary. vue's compiler-sfc asks for postcss
^8.5.8, and the advisories cover <=8.5.22 - so 8.5.25 was always inside the
range npm was allowed to pick. The vulnerability existed only because the
lockfile pinned 8.5.12. Refreshing that pin is the whole fix; no override is
needed to hold it there.

What:
Remove the overrides block. The lockfile keeps postcss 8.5.25, which is a
resolution npm reaches on its own, and `npm audit --omit=dev` reports zero
vulnerabilities.

How to test:
npm ci && npm audit --omit=dev --audit-level=high
Why:
Pointing the workflows at .nvmrc was meant to remove drift, but .nvmrc said
24 while every workflow had hardcoded 22 - so the "fix" silently upgraded CI
by a major version, and install broke everywhere with:

  npm error Missing: yaml@2.9.0 from lock file

Node 22 ships npm 10.x; Node 24.18 ships npm 11.16. lint-staged declares
`yaml` as an optionalDependency, and npm 11.16 requires an entry for it in
the lockfile where npm 10 does not. develop's lockfile has never had that
entry, so this was latent, not caused by anything in this branch.

Regenerating the lockfile on Windows is not the fix: npm 11.16 prunes every
other platform's optional binaries while doing it, dropping 26 entries
including @lmdb/lmdb-linux-x64 and @napi-rs/nice-linux-x64-gnu, which is
exactly what Linux CI needs.

What:
Set .nvmrc to 22 so the declared version matches the one that is actually
tested and that the committed lockfile supports. The workflows keep reading
.nvmrc, so there is still a single source of truth.

Moving to Node 24 is a real upgrade and needs its own PR: the lockfile has to
be regenerated on Linux under npm 11.16 so it keeps the cross-platform
optional binaries and gains the yaml entry.

How to test:
npm ci
Why:
Two failures in the new job.

example/angular-app gitignores its package-lock.json, so in a fresh checkout
there is no lockfile: `cache-dependency-path` could not resolve it ("Some
specified paths were not resolved") and `npm ci` had nothing to install from.

example/react-app failed with module-not-found on
@dynamic-field-kit/core from packages/react/dist/index.mjs. The `file:` deps
resolve to the real packages/ directories, so Node walks up from there to the
workspace root looking for core - and the job never ran a root install, so it
was not there. It passed locally only because a root node_modules already
existed.

What:
- Run `npm ci` at the workspace root before installing each example.
- Use `npm install` for the examples so the lockfile-less angular app works;
  react and vue still honour their committed lockfiles.
- Drop cache-dependency-path so the cache keys off the root lockfile, which
  always exists.

How to test:
rm -rf example/react-app/node_modules
npm ci && cd example/react-app && npm install && npm run build
The release PR consumes changesets and bumps versions, so it changes packages
while correctly having no changeset left. Requiring the check without this
would deadlock every release.
Why:
Releases were being cut by hand - #25 edited versions directly and #27 then
had to delete consumed changeset files manually. Changesets was installed but
half-used, so the version bump was manual work followed by a commit.

What:
Release is now workflow_dispatch only, with inputs:

  bump      patch | minor | major
  packages  core,react,vue,angular  (empty = all)
  message   the CHANGELOG entry
  dry_run   version and print, publish nothing

scripts/create-changeset.js turns those inputs into a real changeset, so the
run goes: quality gates -> changeset version -> lockfile sync -> build ->
commit -> changeset publish. No version is edited by hand. Packages stay
independently versioned, and changesets already committed are consumed in the
same run with the largest bump per package winning.

The push-to-master trigger is gone. It opened a "version packages" PR through
changesets/action, which is a second, competing release path - the same
overlap that produced the manual cleanup in #27.

Run it on develop, not master: required status checks apply to direct pushes,
so the Actions bot cannot push the release commit to master. Versions reach
master through the usual develop -> master PR.

Verified locally against the real changeset on this branch: core 1.3.0 ->
1.4.0, react/vue/angular 1.4.0 -> 1.5.0, internal deps rewritten to ^1.4.0,
CHANGELOGs generated. The lockfile sync step was checked under npm 10 (what
Node 22 ships, per .nvmrc) and keeps every platform's optional binaries -
npm 11 prunes them, which is what broke install earlier on this branch.

How to test:
Actions > Release > Run workflow, on develop, with dry_run enabled.
Why:
The engine shipped canGoNext and canGoPrev - it could say whether moving was
allowed, but there was no function to move. Callers had to rebuild state with
createWizardState(steps, i + 1), which resets completedSteps to [] every
time. completedSteps was written in exactly one place, its initialiser, and
read nowhere: declared state that nothing maintained.

The README advertises a "Multi-Step Form Wizard Engine", so this was the gap
between the claim and what the code could do.

What:
- goNext / goPrev / goToStep, all returning new state and leaving the input
  untouched. goNext records the step it leaves in completedSteps, so the set
  is now maintained rather than decorative.
- markStepCompleted and isStepCompleted for driving a step indicator.
- goNext at the last step and goPrev at the first return the *same* state
  object, so callers can compare identity to detect a no-op.

goNext does not validate. Validation stays explicit through validateStep, so
a wizard can allow moving on from an incomplete step if it wants to.

How to test:
npm run test --workspace=@dynamic-field-kit/core
The react and vue overlays put a red count badge on the collapsed button and
render the errors tab as "errors (N)". Angular had neither, so the one
framework where you cannot see errors at a glance was the one whose overlay
looked identical otherwise.
Why:
useDynamicForm ships handleBlur, touched and validateOnBlur, but there was no
way to connect them to MultiFieldInput - the component that actually renders
the form. The example app tried, with onBlurField, and did not compile.

Worse, blur plumbing existed only in react. Vue and Angular had none at any
level: their FieldInput never passed onBlur down, and Vue's DynamicInput did
not declare it - so the vue default renderers accepted an onBlur prop that
could never arrive.

What:
- react: MultiFieldInput takes an optional onBlurField, called alongside the
  touched tracking it already did internally.
- vue: onBlur and touched threaded through DynamicInput -> FieldInput ->
  MultiFieldInput, plus internal touched tracking to match react.
- angular: FieldInput emits onBlurField from a `focusout` listener - it
  bubbles, so any renderer works without declaring a blur output of its own.
  MultiFieldInput re-emits it and exposes isTouched().
- example: restore onBlurField, now that the prop exists.

How to test:
npm run test --workspace=@dynamic-field-kit/{react,vue,angular}
cd example/react-app && npm run build
Why:
The v1.4 features were a bullet list with no examples, and several shipped
APIs appeared nowhere in the README at all - the wizard navigation, the group
array helpers, defaultRenderersMap/getDefaultRenderer, and the blur wiring.
The built-in renderer list was also stale: it named 7 types when the packages
ship 14.

What:
- Sections with real examples for form state, the wizard, DevTools and the
  group array helpers, each with a table of the exported surface.
- Correct the renderer list, and document what each default emits.
- A "Runnable Examples" section mapping each demo page to what it shows.
- New /wizard page in the react example: step indicator driven by
  completedSteps, per-step validateStep, goNext/goPrev. Linked from the other
  two pages.

Every identifier named in the README was checked against the built dist, and
the example app compiles in CI.

How to test:
cd example/react-app && npm run dev  # then visit /wizard
Why:
There was no link to hand someone who wants to see the library work. The
example apps only ran locally, and running them means cloning the repo and
building four packages first.

What:
- deploy-pages.yml builds all three demos and publishes them under one site:
  /react, /vue, /angular, plus a landing page. It runs on develop and on
  demand, and only when packages/ or example/ changed.
- Each app takes its base path from an env var, so local dev is untouched:
  Next reads PAGES_BASE_PATH, Vite reads it as `base`, Angular gets
  --base-href on the command line.
- Next now emits a static export with trailingSlash, so /wizard resolves to
  wizard/index.html on a plain file host. A .nojekyll file stops Pages from
  stripping _next.
- README gains per-package npm badges and demo links. The repo's About link
  now points at the demos, so npm needed a home in the README.

Also deletes example/react-app/next.config.ts. Next resolves next.config.js
first, so the .ts file - and the `reactCompiler: true` in it - had never
taken effect. Verified by adding output:'export' to the .js and watching out/
appear.

And ignores example/ and smoke/ in .eslintignore. `npm run lint` only covers
packages/*/src, so CI never linted them, but the pre-commit hook did - against
a config written for library source, which rejects a `require` in a Next
config and cannot resolve an example's own dependencies.

Live at https://vannt-dev.github.io/dynamic-field-kit/ once this is on develop.

How to test:
Actions > Deploy demos to Pages > Run workflow
@vannt-dev
vannt-dev merged commit d91c737 into develop Aug 5, 2026
10 checks passed
@vannt-dev
vannt-dev deleted the feature/form-state-hooks-and-advanced-enhancements branch August 5, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant