diff --git a/.changeset/config.json b/.changeset/config.json index 26fd612..7120e00 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,7 +5,7 @@ "fixed": [], "linked": [], "access": "public", - "baseBranch": "master", + "baseBranch": "develop", "updateInternalDependencies": "patch", "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true diff --git a/.changeset/lucky-pugs-shake.md b/.changeset/lucky-pugs-shake.md new file mode 100644 index 0000000..3e5e0a9 --- /dev/null +++ b/.changeset/lucky-pugs-shake.md @@ -0,0 +1,24 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +Add form state hooks, schema adapters, wizard engine, DevTools and extended renderers. + +**Core** + +- `zodValidator`, `yupValidator`, `valibotValidator` / `standardSchemaValidator`. Adapters parse **synchronously** so their result is usable by the synchronous `validateFields`; schemas with async refinements or async `.test()` rules return a Promise and must be validated through `validateFieldsAsync`. +- Adapters take an explicit `{ target: 'form' | 'field' }` option. `'form'` (the default) parses the form data object; `'field'` parses a single scalar value. The field-name shorthand — `zodValidator(schema, 'email')` — is unchanged. +- Wizard engine: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`, plus the navigation the engine needs to be usable - `goNext`, `goPrev`, `goToStep`, `markStepCompleted`, `isStepCompleted`. `goNext` records the step it leaves, so `completedSteps` is actually maintained. +- Group array helpers: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, `focusFirstInvalidField`. +- `switch` is a first-class field type: it had a shipped renderer in react and vue but no `FieldTypeMap` entry, so `type: 'switch'` did not typecheck. + +**React / Vue / Angular** + +- `useDynamicForm` (React, Vue) and `createDynamicFormStore` (Angular Signals) now expose the same surface, including `isSubmitting` and `isSubmitted`. +- `handleSubmit(onValid, onInvalid)` returns a submit handler in every framework and calls `preventDefault` on the event it receives. +- Default HTML5 renderers for `radio`, `range`, `file`, `date`, `time`, `datetime-local` and `switch`. +- `DynamicFormDevTools` overlay for inspecting form data, errors, metadata and field descriptions, with an error-count badge in all three frameworks. +- `MultiFieldInput` reports blur through `onBlurField` (an `@Output` in Angular), so a form store's `handleBlur` / `touched` / `validateOnBlur` can be wired to it. Vue and Angular previously had no blur plumbing at all. diff --git a/.eslintignore b/.eslintignore index fa397d0..be0b7b8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,12 @@ dist/ coverage/** .nyc_output/** *.log + +# The `lint` script only covers packages/*/src, so CI never lints the demo +# apps. Without this, the pre-commit hook lints them anyway - against a config +# written for library source - and fails on things like a `require` in +# next.config.js or an import it cannot resolve from an example's own +# node_modules. The examples are still prettier-checked, typechecked by their +# own builds, and built in CI. +example/** +smoke/** diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9ff0b3a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# Branch protection on master enables require_code_owner_reviews, which does +# nothing without this file. Every path needs an owner for that rule to bite. +* @vannt-dev + +# Anything that decides what ships, or what a release does. +/.github/ @vannt-dev +/.changeset/ @vannt-dev +/scripts/ @vannt-dev +package.json @vannt-dev +package-lock.json @vannt-dev diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..817cf63 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## What + + + +## Why + + + +## How to test + + + +--- + +- [ ] Added a changeset (`npx changeset`) if any package under `packages/` changed +- [ ] Tests cover the change — a bug fix has a test that failed before it +- [ ] Public API changes are reflected in the README / package README +- [ ] Behaviour is consistent across react, vue and angular, or the difference is explained above + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e06ed8..f8dc2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,136 +10,35 @@ concurrency: cancel-in-progress: true jobs: - lint-and-build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Lint & Format - run: npm run lint && npm run format-check - - - name: Build packages - run: | - npm run build --workspace=@dynamic-field-kit/core - npm run build --workspace=@dynamic-field-kit/react - npm run build --workspace=@dynamic-field-kit/vue - npm run build --workspace=@dynamic-field-kit/angular - - # Share the built dist with the verify job so it doesn't rebuild all four - # packages (ng-packagr's Angular build especially). Common ancestor of the - # four paths is `packages`, so the artifact stores `/dist/...`. - - name: Upload built dist - uses: actions/upload-artifact@v4 - with: - name: package-dist - path: | - packages/core/dist - packages/react/dist - packages/vue/dist - packages/angular/dist - retention-days: 1 - if-no-files-found: error - - - name: Typecheck - run: npm run typecheck - - - name: Type tests - run: npm run test:types --workspace=@dynamic-field-kit/core - - - name: Show bundle sizes - run: node scripts/show-sizes.js + gates: + uses: ./.github/workflows/quality-gates.yml - test: - name: Test ${{ matrix.package }} + changeset: + name: Changeset present runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - package: core - test-cmd: npm run test --workspace=@dynamic-field-kit/core -- --coverage - coverage-file: packages/core/coverage/lcov.info - coverage-name: core - - package: react - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test --workspace=@dynamic-field-kit/react -- --coverage - coverage-file: packages/react/coverage/lcov.info - coverage-name: react - - package: vue - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test --workspace=@dynamic-field-kit/vue -- --coverage - coverage-file: packages/vue/coverage/lcov.info - coverage-name: vue - - package: angular - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test --workspace=@dynamic-field-kit/angular -- --coverage - coverage-file: packages/angular/coverage/lcov.info - coverage-name: angular + # Skipped on the release PR changesets itself opens: that PR consumes the + # changesets and bumps versions, so it changes packages while legitimately + # having none left, and would always fail this check. + if: >- + github.event_name == 'pull_request' && + !startsWith(github.head_ref, 'changeset-release/') steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build core - if: matrix.build-cmd != '' - run: ${{ matrix.build-cmd }} - - - name: Run tests - run: ${{ matrix.test-cmd }} - - - name: Upload coverage - if: matrix.coverage-file != '' && success() - uses: codecov/codecov-action@v4 with: - files: ${{ matrix.coverage-file }} - flags: unittests - name: ${{ matrix.coverage-name }} - fail_ci_if_error: false - - verify: - runs-on: ubuntu-latest - needs: [lint-and-build, test] - steps: - - uses: actions/checkout@v4 + # changeset status diffs against the base branch, so it needs history. + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies run: npm ci - # Reuse the dist built by lint-and-build instead of rebuilding all four - # packages. Extract into `packages/` so paths become packages//dist. - - name: Download built dist - uses: actions/download-artifact@v4 - with: - name: package-dist - path: packages - - - name: Smoke test built packages - run: npm run test --workspace=@dynamic-field-kit/smoke - - - name: Run verification scripts - run: | - node scripts/verify-framework-deps.js - node scripts/check-cross-framework-imports.js - node scripts/integration-cross-registry.js + # Fails when a PR changes a publishable package without adding a + # changeset, which would otherwise merge and silently never be released. + # Add one with `npx changeset`. + - name: Check for a changeset + run: npx changeset status --since=origin/${{ github.base_ref }} diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..e8f47fb --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,97 @@ +name: Deploy demos to Pages + +# Publishes the three example apps to GitHub Pages so there is a link to hand +# people. They consume the packages through `file:` paths, so whatever is on +# this branch is exactly what the demos are built against. +on: + push: + branches: [develop] + # Only rebuild when something the demos actually render could have changed. + paths: + - 'packages/**' + - 'example/**' + - '.github/workflows/deploy-pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time, and never cancel one midway. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + env: + # Project pages live under //, so every app needs its assets + # prefixed or they 404. + SITE_BASE: /${{ github.event.repository.name }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install workspace dependencies + run: npm ci + + - name: Build packages + run: npm run build + + - name: Build react demo + working-directory: example/react-app + env: + PAGES_BASE_PATH: ${{ env.SITE_BASE }}/react + run: | + npm install --no-audit --no-fund + npm run build + + - name: Build vue demo + working-directory: example/vue-app + env: + PAGES_BASE_PATH: ${{ env.SITE_BASE }}/vue/ + run: | + npm install --no-audit --no-fund + npm run build + + - name: Build angular demo + working-directory: example/angular-app + run: | + npm install --no-audit --no-fund + npx ng build --base-href ${{ env.SITE_BASE }}/angular/ + + - name: Assemble the site + run: | + mkdir -p _site + cp example/pages-index.html _site/index.html + cp -r example/react-app/out _site/react + cp -r example/vue-app/dist _site/vue + cp -r example/angular-app/dist/example-angular-app _site/angular + # Pages runs the output through Jekyll otherwise, which drops + # directories starting with an underscore - _next among them. + touch _site/.nojekyll + echo "--- deployed tree ---" + find _site -maxdepth 2 -name index.html + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 0000000..f937858 --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,189 @@ +name: Quality Gates + +# The single definition of "is this commit good?". Called by both ci.yml and +# release.yml, so a publish can never run against checks weaker than a PR's. +on: + workflow_call: + +jobs: + lint-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint & Format + run: npm run lint && npm run format-check + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Build packages + run: | + npm run build --workspace=@dynamic-field-kit/core + npm run build --workspace=@dynamic-field-kit/react + npm run build --workspace=@dynamic-field-kit/vue + npm run build --workspace=@dynamic-field-kit/angular + + # Share the built dist with the verify and examples jobs so they don't + # rebuild all four packages (ng-packagr's Angular build especially). + # Common ancestor of the four paths is `packages`, so the artifact stores + # `/dist/...`. + - name: Upload built dist + uses: actions/upload-artifact@v4 + with: + name: package-dist + path: | + packages/core/dist + packages/react/dist + packages/vue/dist + packages/angular/dist + retention-days: 1 + if-no-files-found: error + + - name: Typecheck + run: npm run typecheck + + - name: Type tests + run: npm run test:types --workspace=@dynamic-field-kit/core + + - name: Show bundle sizes + run: node scripts/show-sizes.js + + test: + name: Test ${{ matrix.package }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package: core + test-cmd: npm run test --workspace=@dynamic-field-kit/core -- --coverage + coverage-file: packages/core/coverage/lcov.info + coverage-name: core + - package: react + build-cmd: npm run build --workspace=@dynamic-field-kit/core + test-cmd: npm run test --workspace=@dynamic-field-kit/react -- --coverage + coverage-file: packages/react/coverage/lcov.info + coverage-name: react + - package: vue + build-cmd: npm run build --workspace=@dynamic-field-kit/core + test-cmd: npm run test --workspace=@dynamic-field-kit/vue -- --coverage + coverage-file: packages/vue/coverage/lcov.info + coverage-name: vue + - package: angular + build-cmd: npm run build --workspace=@dynamic-field-kit/core + test-cmd: npm run test --workspace=@dynamic-field-kit/angular -- --coverage + coverage-file: packages/angular/coverage/lcov.info + coverage-name: angular + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build core + if: matrix.build-cmd != '' + run: ${{ matrix.build-cmd }} + + - name: Run tests + run: ${{ matrix.test-cmd }} + + - name: Upload coverage + if: matrix.coverage-file != '' && success() + uses: codecov/codecov-action@v4 + with: + files: ${{ matrix.coverage-file }} + flags: unittests + name: ${{ matrix.coverage-name }} + fail_ci_if_error: false + + examples: + name: Example ${{ matrix.app }} + runs-on: ubuntu-latest + needs: [lint-and-build] + strategy: + fail-fast: false + matrix: + app: [react-app, vue-app, angular-app] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + # The example apps depend on the packages via `file:` paths, so the dist + # has to exist before their install resolves them. + - name: Download built dist + uses: actions/download-artifact@v4 + with: + name: package-dist + path: packages + + # Those `file:` paths resolve to the real packages/ directories, so module + # resolution from packages/react/dist walks up to the workspace root - and + # without a root install it never finds @dynamic-field-kit/core. + - name: Install workspace dependencies + run: npm ci + + # `npm install`, not `npm ci`: example/angular-app gitignores its + # package-lock.json, so there is no lockfile to clean-install from. The + # other two still honour their committed lockfiles. + - name: Install example dependencies + working-directory: example/${{ matrix.app }} + run: npm install --no-audit --no-fund + + # Each example's build typechecks its sources against the built packages, + # which is what catches a README/demo drifting from the real API. + - name: Build example + working-directory: example/${{ matrix.app }} + run: npm run build + + verify: + runs-on: ubuntu-latest + needs: [lint-and-build, test] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Reuse the dist built by lint-and-build instead of rebuilding all four + # packages. Extract into `packages/` so paths become packages//dist. + - name: Download built dist + uses: actions/download-artifact@v4 + with: + name: package-dist + path: packages + + - name: Smoke test built packages + run: npm run test --workspace=@dynamic-field-kit/smoke + + - name: Run verification scripts + run: | + node scripts/verify-framework-deps.js + node scripts/check-cross-framework-imports.js + node scripts/integration-cross-registry.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 169557f..741cfe6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,48 +1,123 @@ name: Release +# Manual only. Pick a bump, pick the packages, press Run - the workflow does +# the version bump, the CHANGELOG, the commit and the npm publish. Nothing is +# versioned by hand. +# +# Run it on `develop`. master requires status checks, and those apply to direct +# pushes too, so the release commit cannot be pushed there by the Actions bot. +# The version bump reaches master through the normal develop -> master PR. on: - push: - branches: - - master + workflow_dispatch: + inputs: + bump: + description: 'Version bump to apply' + type: choice + options: + - patch + - minor + - major + default: patch + required: true + packages: + description: 'Packages to bump, comma separated (core,react,vue,angular). Leave empty for all.' + type: string + default: '' + required: false + message: + description: 'CHANGELOG entry for this release' + type: string + default: '' + required: false + dry_run: + description: 'Version and print what would be published, but do not publish' + type: boolean + default: false + required: false +# Deliberately no cancel-in-progress: cancelling mid-publish could leave some +# packages on npm and others not. concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write - pull-requests: write jobs: + # The exact gates a PR faces, so a release can never ship something CI would + # have rejected. + gates: + uses: ./.github/workflows/quality-gates.yml + release: name: Release & Publish + needs: [gates] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'npm' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci + # Turns the dispatch inputs into a real changeset. Any changesets already + # committed are consumed in the same pass, and the largest bump per + # package wins. + - name: Create changeset from inputs + run: | + node scripts/create-changeset.js \ + --bump "${{ inputs.bump }}" \ + --packages "${{ inputs.packages }}" \ + --message "${{ inputs.message }}" + + - name: Apply versions and changelogs + run: npx changeset version + + # changeset version rewrites the workspace package.json versions, which + # the lockfile records too - without this the next `npm ci` fails. + - name: Sync lockfile + run: npm install --package-lock-only --no-audit --no-fund + + - name: Show resulting versions + run: | + for pkg in core react vue angular; do + echo "$pkg -> $(node -p "require('./packages/$pkg/package.json').version")" + done + - name: Build packages run: npm run build - - name: Typecheck & Verify Tests - run: npm run typecheck && npm test + - name: Commit the release + if: ${{ !inputs.dry_run }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'chore(release): version packages' + git push origin HEAD:${{ github.ref_name }} - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: changesets/action@v1 - with: - version: npm run version-packages - publish: npm run release - commit: 'chore: release packages' - title: 'chore(release): version packages' + # changeset publish pushes each package that is not already on npm at its + # current version, and tags the commit per package. + - name: Publish to npm + if: ${{ !inputs.dry_run }} + run: | + npx changeset publish + git push origin --follow-tags env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + echo 'Dry run - nothing was committed, tagged or published.' + echo 'Versions that would have shipped are listed above.' + git --no-pager diff --stat diff --git a/.nvmrc b/.nvmrc index a45fd52..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24 +22 diff --git a/README.md b/README.md index a8a5447..6b4a552 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ # Dynamic Field Kit [![CI](https://github.com/vannt-dev/dynamic-field-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/vannt-dev/dynamic-field-kit/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/core?label=core)](https://www.npmjs.com/package/@dynamic-field-kit/core) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/react?label=react)](https://www.npmjs.com/package/@dynamic-field-kit/react) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/vue?label=vue)](https://www.npmjs.com/package/@dynamic-field-kit/vue) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/angular?label=angular)](https://www.npmjs.com/package/@dynamic-field-kit/angular) + +**[▶ Live demos](https://vannt-dev.github.io/dynamic-field-kit/)** — the same +schema rendered by [React](https://vannt-dev.github.io/dynamic-field-kit/react/), +[Vue](https://vannt-dev.github.io/dynamic-field-kit/vue/) and +[Angular](https://vannt-dev.github.io/dynamic-field-kit/angular/), including a +[multi-step wizard](https://vannt-dev.github.io/dynamic-field-kit/react/wizard/). A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, built for scalable applications and design systems. @@ -21,6 +31,43 @@ A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, b - Framework-agnostic core (works with React, Angular, Vue, or vanilla JS) - Ideal for form builders & design systems +### 🚀 Enterprise Features (v1.4+) + +- **Form State Hook / Composable / Signal Store**: `useDynamicForm` for React & Vue 3, `createDynamicFormStore` for Angular Signals. All three expose the same surface — including `isSubmitting` / `isSubmitted` — and `handleSubmit(onValid, onInvalid)` returns a submit handler in every framework. +- **Extended HTML5 Renderers**: Built-in support for `radio`, `range`, `file`, `date`, `time`, `datetime-local`, and `switch`. +- **Schema Validation Adapters**: Integrated `zodValidator`, `yupValidator`, `valibotValidator`, and Standard Schema adapters. +- **Multi-Step Form Wizard Engine**: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`, `goNext`, `goPrev`, `goToStep`, `markStepCompleted`, `isStepCompleted`. State is immutable — every navigation returns a new state, and `goNext` records the step it leaves in `completedSteps`. +- **Interactive Form DevTools**: Floating overlay component (``) for realtime debugging. +- **Blur wiring**: `MultiFieldInput` reports blur via `onBlurField` (an `@Output` in Angular), so a form store's `handleBlur` / `touched` / `validateOnBlur` can be connected to it. +- **Group Array Manipulation Helpers**: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, and `focusFirstInvalidField`. + +#### Schema adapters + +Attach an adapter to a field's `validate` hook. By default the schema is treated +as an **object schema describing the whole form**, and a field name selects which +issues to surface: + +```ts +import { zodValidator } from '@dynamic-field-kit/core'; + +const schema = z.object({ email: z.string().email() }); + +const fields = [ + { name: 'email', type: 'text', validate: zodValidator(schema, 'email') }, +]; +``` + +For a **scalar schema** covering a single value, say so explicitly: + +```ts +validate: zodValidator(z.string().email(), { target: 'field' }); +``` + +Adapters parse **synchronously** so their result works with `validateFields` (and +therefore with `useDynamicForm`). A schema containing async refinements or async +`.test()` rules cannot be parsed synchronously — those return a Promise, so +validate through `validateFieldsAsync` instead. + --- ## 📦 Packages @@ -265,10 +312,29 @@ const asyncResult = await validateFieldsAsync(fields, data); **Default Built-in HTML5 Renderers (Zero Config)** -All framework adapters (`react`, `vue`, `angular`) ship with **built-in HTML5 fallback renderers** for common input types: -`'text'`, `'number'`, `'select'`, `'checkbox'`, `'textarea'`, `'password'`, `'email'`. +All framework adapters (`react`, `vue`, `angular`) ship with **built-in HTML5 fallback renderers**: + +`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` · +`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch` + +If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states. + +Every key above is also a `FieldTypeMap` entry, so `type: 'switch'` typechecks +out of the box. The map itself is reachable if you need to wrap or inspect a +default: + +```ts +import { + defaultRenderersMap, + getDefaultRenderer, +} from '@dynamic-field-kit/react'; // or /vue + +const Base = getDefaultRenderer('date'); // undefined for an unknown type +Object.keys(defaultRenderersMap); // every built-in type key +``` -If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states! +`file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number` +emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings. **Custom Field Registry (Custom UI & Design Systems)** @@ -310,6 +376,189 @@ registry.register('text', myTextRenderer); --- +## 🧾 Form State (`useDynamicForm`) + +Holds the data, errors, touched and submission state for a set of fields. React +and Vue export `useDynamicForm`; Angular exports `createDynamicFormStore`, built +on signals. All three expose the same surface. + +```tsx +// React +import { useDynamicForm, MultiFieldInput } from '@dynamic-field-kit/react'; + +const form = useDynamicForm({ + fields, + initialValues: { country: 'VN' }, + validateOnBlur: true, // default + validateOnChange: false, // default +}); + +
save(data))}> + + +; +``` + +```ts +// Vue — same names, refs instead of plain values +const form = useDynamicForm({ fields }); +form.data.value; +form.isSubmitting.value; + +// Angular — same names, signals +const store = createDynamicFormStore({ fields }); +store.data(); +store.isSubmitting(); +``` + +| Member | Description | +| ----------------------------------- | --------------------------------------------------------------------------------- | +| `data` | Current form data, with `computeValue` fields applied | +| `errors` | `Record`, keyed like `validateFields` | +| `isValid` / `isDirty` | No errors recorded / any value has changed | +| `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted | +| `touched` | Fields that have been blurred | +| `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` | +| `setFieldValue(name, value)` | Change one field | +| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` | +| `setFieldTouched(name, value?)` | Set touched explicitly | +| `validate()` | Validate now, returns a boolean | +| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | +| `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches | + +`handleSubmit` returns a **handler** in every framework, so Angular binds it the +same way: `onSubmit = this.store.handleSubmit((data) => …)` then +`(ngSubmit)="onSubmit($event)"`. + +## 🧭 Multi-Step Wizard + +A framework-agnostic state machine over grouped `FieldDescription`s. State is +immutable — every navigation returns a new state object. + +```ts +import { + createWizardState, + validateStep, + goNext, + goPrev, + canGoNext, + isStepCompleted, +} from '@dynamic-field-kit/core'; + +const steps = [ + { id: 'account', title: 'Account', fields: accountFields }, + { id: 'profile', title: 'Profile', fields: profileFields }, +]; + +let wizard = createWizardState(steps); + +function next(data) { + // goNext does not validate — decide for yourself whether the step may be left + const { valid, errors } = validateStep(wizard.currentStep, data); + if (!valid) return errors; + wizard = goNext(wizard); // records the step it leaves in completedSteps +} +``` + +| Export | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------- | +| `createWizardState(steps, index?)` | Initial state; `index` is clamped into range | +| `validateStep(step, data)` | `{ valid, errors }` for one step's fields | +| `canGoNext` / `canGoPrev` | Whether a move is possible | +| `goNext` / `goPrev` | Move one step. Returns the **same object** when it cannot, so `===` detects a no-op | +| `goToStep(state, index)` | Jump anywhere (clamped). Does not mark anything completed | +| `markStepCompleted(state, index?)` | Record a step as done; defaults to the current one | +| `isStepCompleted(state, index)` | For rendering a step indicator | + +`WizardState` carries `currentStep`, `currentStepIndex`, `totalSteps`, +`isFirstStep`, `isLastStep`, `steps` and `completedSteps`. + +## 🛠️ DevTools + +A floating overlay showing live form data, errors, metadata and field +descriptions. Render it next to your form during development. + +```tsx +import { DynamicFormDevTools } from '@dynamic-field-kit/react'; // or /vue + +; +``` + +```html + + +``` + +The collapsed button carries a red badge with the number of fields in error. + +## 🧮 Group Array Helpers + +`MultiFieldInput` renders add/remove controls for repeatable groups on its own. +These helpers are for driving a group's array yourself — a drag handle, a +"duplicate row" button, a custom group renderer: + +```ts +import { + moveGroupItem, + swapGroupItems, + insertGroupItem, + isFieldGroup, + createGroupItem, + canAddGroupItem, + canRemoveGroupItem, + focusFirstInvalidField, +} from '@dynamic-field-kit/core'; + +const reordered = moveGroupItem(items, 3, 0); // returns the SAME array if out of range +const withRow = insertGroupItem(items, 1, createGroupItem(field)); + +if (canAddGroupItem(field, items)) { + /* respects maxItems */ +} + +// After a failed submit: focus + scroll to the first [aria-invalid="true"] field +focusFirstInvalidField(formElement); +``` + +All of them are pure — they return a new array and never mutate the input. + +--- + +## ▶️ Runnable Examples + +`example/` holds a working app per framework. They consume the packages through +`file:` paths, so build the packages first: + +```bash +npm run build # from the repo root +cd example/react-app && npm install && npm run dev +``` + +| Page | Shows | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `/` (react, vue, angular) | Registering renderers, `MultiFieldInput`, layouts, conditions, repeatable groups | +| `/new-features` (react) | `useDynamicForm`, the extended HTML5 renderers, blur wiring via `onBlurField`, `DynamicFormDevTools` | +| `/wizard` (react) | The wizard engine end to end: step indicator from `completedSteps`, per-step `validateStep`, `goNext` / `goPrev` | + +CI builds all three example apps on every PR, so the code above is guaranteed +to compile against the current packages. + +--- + ## 📖 Framework-Specific Usage For detailed setup and component API: @@ -395,6 +644,31 @@ This library intentionally does not include: It is a **form engine**, not a full form framework. +## 🚀 Releasing + +Versions are never edited by hand. Go to **Actions → Release → Run workflow**, +run it on `develop`, and fill in: + +| Input | Meaning | +| ---------- | ---------------------------------------------------------------------- | +| `bump` | `patch`, `minor` or `major` | +| `packages` | `core,react,vue,angular` — leave empty to bump all of them | +| `message` | The CHANGELOG entry for this release | +| `dry_run` | Version and print the result without committing, tagging or publishing | + +The workflow runs the full quality gates first, then applies the bump, writes +the CHANGELOGs, commits `chore(release): version packages`, and publishes to +npm. + +Packages are versioned independently, so bumping only what changed is fine. +Any changesets already committed (`npx changeset`, or +`npm run changeset:auto -- --bump minor --packages core --message "..."`) are +consumed in the same run, and the largest bump per package wins. + +Run it on `develop`, not `master`: master requires status checks, and those +apply to direct pushes too, so the Actions bot cannot push the release commit +there. The new versions reach master through the usual `develop → master` PR. + ## 📄 License MIT © [vannt-dev](https://github.com/vannt-dev) diff --git a/example/pages-index.html b/example/pages-index.html new file mode 100644 index 0000000..549a82f --- /dev/null +++ b/example/pages-index.html @@ -0,0 +1,170 @@ + + + + + + Dynamic Field Kit — live demos + + + + +
+

Dynamic Field Kit

+

+ One schema-driven form engine, three frameworks. The same + FieldDescription[] renders below in React, Vue and Angular. +

+ + + +

What each React page shows

+
    +
  • + Basics — registering renderers, + MultiFieldInput, layouts, conditional fields, repeatable + groups +
  • +
  • + Enterprise features — + useDynamicForm, the extended HTML5 renderers, blur + wiring, + DynamicFormDevTools +
  • +
  • + Multi-step wizard — + createWizardState, per-step validateStep, + goNext/goPrev, completed-step indicator +
  • +
+ +
+ Built from the + repository + on every push — these demos compile against the packages in the same + commit. Packages on + npm. +
+
+ + diff --git a/example/react-app/app/new-features/page.tsx b/example/react-app/app/new-features/page.tsx index e36a973..cb964aa 100644 --- a/example/react-app/app/new-features/page.tsx +++ b/example/react-app/app/new-features/page.tsx @@ -1,13 +1,12 @@ 'use client'; +import { FieldDescription, validators } from '@dynamic-field-kit/core'; import { - FieldDescription, - validators, - validateFieldsAsync, -} from '@dynamic-field-kit/core'; -import { MultiFieldInput } from '@dynamic-field-kit/react'; + MultiFieldInput, + useDynamicForm, + DynamicFormDevTools, +} from '@dynamic-field-kit/react'; import Link from 'next/link'; -import { useState } from 'react'; import '../../lib/fieldRegistry'; const fields: FieldDescription[] = [ @@ -22,34 +21,27 @@ const fields: FieldDescription[] = [ validate: validators.required('Vui lòng chọn quốc gia'), }, { - name: 'city', - type: 'select', - label: '2. Thành phố (Phụ thuộc vào Quốc gia đã chọn)', - // Dynamic options function evaluated on data change - options: (data) => { - if (data.country === 'VN') { - return [ - { label: 'Hà Nội', value: 'HN' }, - { label: 'TP. Hồ Chí Minh', value: 'HCM' }, - { label: 'Đà Nẵng', value: 'DN' }, - ]; - } - if (data.country === 'US') { - return [ - { label: 'New York', value: 'NY' }, - { label: 'Los Angeles', value: 'LA' }, - { label: 'Chicago', value: 'CHI' }, - ]; - } - return []; - }, - disabledCondition: (data) => !data.country, - validate: validators.required('Vui lòng chọn thành phố'), + name: 'gender', + type: 'radio', + label: '2. Giới tính (Extended HTML5 Radio)', + options: [ + { label: 'Nam', value: 'male' }, + { label: 'Nữ', value: 'female' }, + { label: 'Khác', value: 'other' }, + ], + }, + { + name: 'satisfaction', + type: 'range', + label: '3. Mức độ hài lòng (Extended Range Slider)', + min: 1, + max: 10, + step: 1, }, { name: 'email', - type: 'text', - label: '3. Email (Built-in Validators: required + email)', + type: 'email', + label: '4. Email (Built-in Validators)', placeholder: 'example@domain.com', validate: validators.compose( validators.required('Email bắt buộc'), @@ -57,72 +49,33 @@ const fields: FieldDescription[] = [ ), }, { - name: 'username', - type: 'text', - label: '4. Username (Async validation simulation)', - placeholder: 'Nhập username (thử "admin")', - validate: async (value) => { - if (!value) { - return 'Username bắt buộc'; - } - // Simulate API call check - if (String(value).toLowerCase() === 'admin') { - return 'Tên "admin" đã tồn tại, vui lòng chọn tên khác'; - } - return undefined; - }, - }, - { - name: 'enableExtra', - type: 'select', - label: '5. Hiển thị trường bổ sung? (appearCondition)', - options: [ - { label: 'Không', value: 'no' }, - { label: 'Có', value: 'yes' }, - ], - }, - { - name: 'note', - type: 'text', - label: 'Ghi chú thêm (Xuất hiện khi chọn "Có")', - appearCondition: (data) => data.enableExtra === 'yes', + name: 'birthDate', + type: 'date', + label: '5. Ngày sinh (Native Date Picker)', }, { - name: 'lockAll', - type: 'select', - label: '6. Khóa trường số điện thoại? (disabledCondition)', - options: [ - { label: 'Mở khóa', value: 'unlocked' }, - { label: 'Khóa (Disabled)', value: 'locked' }, - ], - }, - { - name: 'phone', - type: 'text', - label: 'Số điện thoại', - placeholder: '0901234567', - disabledCondition: (data) => data.lockAll === 'locked', + name: 'subscribeNewsletter', + type: 'switch', + label: '6. Nhận bản tin ưu đãi (Switch Toggle)', }, ]; export default function NewFeaturesPage() { - const [data, setData] = useState>({ country: 'VN' }); - const [errors, setErrors] = useState>({}); - const [validating, setValidating] = useState(false); - - const handleValidate = async () => { - setValidating(true); - // Test async validation - const res = await validateFieldsAsync(fields, data); - setErrors(res.errors); - setValidating(false); - }; + const form = useDynamicForm({ + fields, + initialValues: { + country: 'VN', + satisfaction: 8, + subscribeNewsletter: true, + }, + validateOnBlur: true, + }); return (
← Demo Cơ Bản (Legacy) - ✨ Demo Tính Năng Mới (v1.3+) + + ✨ Demo Enterprise Features (v1.4+) + + + 🧭 Wizard nhiều bước → +

- Tính Năng Mới v1.3+ Engine + Tính Năng Nâng Cấp Enterprise-Grade (v1.4+)

- Trang này minh họa các tính năng mới gồm: Built-in validators - (`required`, `email`, `compose`), Dynamic Options (Options thay đổi theo - Quốc gia), Conditional Disabled & Appear, và Async Validation. + Minh họa: useDynamicForm state management hook, Extended + Renderers (radio, range, date,{' '} + switch), và Realtime DynamicFormDevTools ở góc + màn hình.

- setErrors(res.errors)} - layout={{ - type: 'responsive', - mobile: 'column', - desktop: { type: 'grid', columns: 2, gap: 16 }, - }} - /> - -
+ alert(`Submit thành công:\n${JSON.stringify(validData, null, 2)}`) + )} > - -
+ + + + +

- Current State (Data): + Form State (useDynamicForm):

-          {JSON.stringify(data, null, 2)}
+          {JSON.stringify(
+            {
+              data: form.data,
+              isDirty: form.isDirty,
+              isValid: form.isValid,
+              errors: form.errors,
+            },
+            null,
+            2
+          )}
         
- {Object.keys(errors).length > 0 && ( - <> -

- Validation Errors: -

-
-              {JSON.stringify(errors, null, 2)}
-            
- - )}
+ + {/* Floating DevTools */} +
); } diff --git a/example/react-app/app/page.tsx b/example/react-app/app/page.tsx index a4a00cb..2b5ac6a 100644 --- a/example/react-app/app/page.tsx +++ b/example/react-app/app/page.tsx @@ -61,12 +61,23 @@ export default function Page() { href="/new-features" style={{ fontWeight: 'normal', + marginRight: '16px', color: '#0066cc', textDecoration: 'none', }} > ✨ Demo Tính Năng Mới (v1.3+) → + + 🧭 Wizard nhiều bước → +

Dynamic Field Kit React Demo

diff --git a/example/react-app/app/wizard/page.tsx b/example/react-app/app/wizard/page.tsx new file mode 100644 index 0000000..c62f3ae --- /dev/null +++ b/example/react-app/app/wizard/page.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { + canGoPrev, + createWizardState, + FieldDescription, + FormStep, + goNext, + goPrev, + isStepCompleted, + validators, + validateStep, +} from '@dynamic-field-kit/core'; +import { MultiFieldInput } from '@dynamic-field-kit/react'; +import Link from 'next/link'; +import { useState } from 'react'; +import '../../lib/fieldRegistry'; + +const accountFields: FieldDescription[] = [ + { + name: 'email', + type: 'email', + label: 'Email', + placeholder: 'example@domain.com', + validate: validators.compose( + validators.required('Email bắt buộc'), + validators.email('Định dạng email không hợp lệ') + ), + }, + { + name: 'password', + type: 'password', + label: 'Mật khẩu', + validate: validators.compose( + validators.required('Mật khẩu bắt buộc'), + validators.minLength(8, 'Tối thiểu 8 ký tự') + ), + }, +]; + +const profileFields: FieldDescription[] = [ + { + name: 'fullName', + type: 'text', + label: 'Họ và tên', + validate: validators.required('Họ tên bắt buộc'), + }, + { + name: 'birthDate', + type: 'date', + label: 'Ngày sinh', + }, +]; + +const preferenceFields: FieldDescription[] = [ + { + name: 'plan', + type: 'radio', + label: 'Gói dịch vụ', + options: [ + { label: 'Miễn phí', value: 'free' }, + { label: 'Pro', value: 'pro' }, + ], + validate: validators.required('Vui lòng chọn gói'), + }, + { + name: 'newsletter', + type: 'switch', + label: 'Nhận bản tin ưu đãi', + }, +]; + +const steps: FormStep[] = [ + { id: 'account', title: 'Tài khoản', fields: accountFields }, + { id: 'profile', title: 'Hồ sơ', fields: profileFields }, + { id: 'preferences', title: 'Tuỳ chọn', fields: preferenceFields }, +]; + +export default function WizardPage() { + const [wizard, setWizard] = useState(() => createWizardState(steps)); + const [data, setData] = useState>({}); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + // goNext deliberately does not validate - the wizard decides whether a step + // may be left, so a "save draft and come back" flow is possible too. + function handleNext() { + const result = validateStep(wizard.currentStep, data); + setErrors(result.errors); + if (result.valid) { + setWizard(goNext(wizard)); + } + } + + function handleFinish() { + const result = validateStep(wizard.currentStep, data); + setErrors(result.errors); + if (result.valid) { + setSubmitted(true); + } + } + + return ( +
+ + +

+ Multi-Step Form Wizard +

+

+ Minh hoạ createWizardState, validateStep,{' '} + goNext / goPrevcompletedSteps + . State là bất biến — mỗi lần điều hướng trả về một state mới. +

+ + {/* Step indicator, driven by isStepCompleted */} +
    + {wizard.steps.map((step, index) => { + const isCurrent = index === wizard.currentStepIndex; + const done = isStepCompleted(wizard, index); + return ( +
  1. + {done ? '✓ ' : `${index + 1}. `} + {step.title} +
  2. + ); + })} +
+ + {submitted ? ( +
+ 🎉 Hoàn tất! +
+            {JSON.stringify(data, null, 2)}
+          
+
+ ) : ( + <> +

+ Bước {wizard.currentStepIndex + 1}/{wizard.totalSteps}:{' '} + {wizard.currentStep.title} +

+ + {/* Only the current step's fields are rendered */} + + + {Object.keys(errors).length > 0 && ( +
    + {Object.entries(errors).map(([field, messages]) => ( +
  • + {field}: {messages.join(', ')} +
  • + ))} +
+ )} + +
+ + + {wizard.isLastStep ? ( + + ) : ( + + )} +
+ + )} +
+ ); +} diff --git a/example/react-app/next.config.js b/example/react-app/next.config.js index 067c288..a142f30 100644 --- a/example/react-app/next.config.js +++ b/example/react-app/next.config.js @@ -1,11 +1,25 @@ const path = require('path'); +// Set by the Pages workflow to '/dynamic-field-kit/react'. Empty locally, so +// `next dev` and `next build` keep serving the app at the root. +const basePath = process.env.PAGES_BASE_PATH || ''; + /** @type {import('next').NextConfig} */ const nextConfig = { // Silence turbopack root warning in monorepos by explicitly setting the root turbopack: { root: path.resolve(__dirname, '../../'), }, + // Static HTML export. Every route here is already prerendered, and this is + // what lets the demo be served from GitHub Pages. + output: 'export', + basePath, + assetPrefix: basePath || undefined, + // Pages has no Next image optimizer behind it. + images: { unoptimized: true }, + // Emit `wizard/index.html` rather than `wizard.html`, so a static host + // resolves /wizard without needing a rewrite rule. + trailingSlash: true, }; module.exports = nextConfig; diff --git a/example/react-app/next.config.ts b/example/react-app/next.config.ts deleted file mode 100644 index 4e93457..0000000 --- a/example/react-app/next.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - /* config options here */ - reactCompiler: true, -}; - -export default nextConfig; diff --git a/example/vue-app/vite.config.ts b/example/vue-app/vite.config.ts index 7e34266..3618fcc 100644 --- a/example/vue-app/vite.config.ts +++ b/example/vue-app/vite.config.ts @@ -4,6 +4,9 @@ import vue from '@vitejs/plugin-vue'; // https://vite.dev/config/ export default defineConfig({ + // Set by the Pages workflow to '/dynamic-field-kit/vue/'. Defaults to '/' so + // `vite dev` and a plain `vite build` are unaffected. + base: process.env.PAGES_BASE_PATH || '/', plugins: [vue()], resolve: { alias: { diff --git a/package-lock.json b/package-lock.json index 022b82c..32be7b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -885,6 +885,35 @@ "@napi-rs/nice": "^1.0.1" } }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -16876,9 +16905,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -17851,9 +17880,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -17871,7 +17900,7 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -18120,6 +18149,13 @@ "dev": true, "license": "MIT" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -20245,6 +20281,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -20319,6 +20362,13 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -22333,6 +22383,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zone.js": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", @@ -23100,7 +23186,9 @@ "@vitest/coverage-istanbul": "^0.34.6", "rimraf": "^6.1.3", "tsup": "^8.0.1", - "vitest": "^0.34.0" + "vitest": "^0.34.0", + "yup": "^1.7.1", + "zod": "^4.4.3" } }, "packages/core/node_modules/@vitest/coverage-istanbul": { diff --git a/package.json b/package.json index 66e6d98..2aafb23 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "format-check": "prettier --check .", "typecheck": "npm run typecheck --workspaces --if-present", "format": "prettier --write .", - "prepare": "husky", + "prepare": "husky && git config commit.template .git-commit-template.txt || true", "changeset": "changeset", + "changeset:auto": "node scripts/create-changeset.js", "version-packages": "changeset version", "release": "changeset publish" }, @@ -51,12 +52,13 @@ "react-dom": "^19.2.4", "vue": "^3.5.32" }, + "//lint-staged": "globs must stay a superset of what `prettier --check .` sees, or a file can pass pre-commit and fail CI format-check. eslint only runs on the extensions the lint script targets.", "lint-staged": { "*.{ts,tsx,js,jsx}": [ "prettier --write", "eslint --fix" ], - "*.{json,md,yml,css}": [ + "*.{cjs,mjs,json,md,yml,yaml,css,scss,html}": [ "prettier --write" ] } diff --git a/packages/angular/package.json b/packages/angular/package.json index 854932a..ee2206b 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -9,6 +9,9 @@ "main": "dist/fesm2022/dynamic-field-kit-angular.mjs", "module": "dist/fesm2022/dynamic-field-kit-angular.mjs", "types": "dist/index.d.ts", + "files": [ + "dist" + ], "peerDependencies": { "@angular/common": ">=13 <22", "@angular/core": ">=13 <22", diff --git a/packages/angular/src/components/DynamicFormDevTools.ts b/packages/angular/src/components/DynamicFormDevTools.ts new file mode 100644 index 0000000..439f0be --- /dev/null +++ b/packages/angular/src/components/DynamicFormDevTools.ts @@ -0,0 +1,171 @@ +import { NgIf, NgFor, JsonPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + signal, +} from '@angular/core'; +import { FieldDescription, Properties } from '@dynamic-field-kit/core'; + +@Component({ + selector: 'dfk-dev-tools', + standalone: true, + imports: [NgIf, NgFor, JsonPipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + +
+
+ 🛠️ Form DevTools + +
+ +
+ +
+ +
+
+          {{ data | json }}
+        
+
+          {{ errors | json }}
+        
+
+
isDirty: {{ isDirty }}
+
{{
+            touched | json
+          }}
+
+
+
+
{{ f.name }}
+
+ type: {{ f.type }} +
+
+
+
+
+ `, +}) +export class DynamicFormDevToolsComponent { + @Input() data: Properties = {}; + @Input() errors: Record = {}; + @Input() touched: Record = {}; + @Input() isDirty = false; + @Input() fields: FieldDescription[] = []; + + /** Number of fields carrying errors, mirroring the react and vue overlays. */ + errorCount(): number { + return Object.keys(this.errors || {}).length; + } + + isOpen = signal(false); + activeTab = signal<'data' | 'errors' | 'meta' | 'fields'>('data'); + tabs: Array<'data' | 'errors' | 'meta' | 'fields'> = [ + 'data', + 'errors', + 'meta', + 'fields', + ]; +} diff --git a/packages/angular/src/components/FieldInput.ts b/packages/angular/src/components/FieldInput.ts index 6c6f75e..cb8aeff 100644 --- a/packages/angular/src/components/FieldInput.ts +++ b/packages/angular/src/components/FieldInput.ts @@ -31,6 +31,7 @@ import { DynamicInput } from './DynamicInput'; (valueChange)=" onValueChangeField.emit({ value: $event, key: fieldDescription!.name }) " + (focusout)="onBlurField.emit(fieldDescription!.name)" [disabled]="disabled" [readOnly]="readOnly" [error]="$any(error)" @@ -49,6 +50,12 @@ export class FieldInput implements OnChanges { value: unknown; key: string; }>(); + /** + * Emits this field's name when focus leaves it. Driven by `focusout`, which + * bubbles, so it works for any renderer without the renderer having to + * declare a blur output of its own. + */ + @Output() onBlurField = new EventEmitter(); shouldRender = false; diff --git a/packages/angular/src/components/MultiFieldInput.ts b/packages/angular/src/components/MultiFieldInput.ts index 927964e..4161031 100644 --- a/packages/angular/src/components/MultiFieldInput.ts +++ b/packages/angular/src/components/MultiFieldInput.ts @@ -64,6 +64,7 @@ const DEFAULT_BREAKPOINT = 768; [readOnly]="getReadOnly(field)" [error]="getError(field)" (onValueChangeField)="onFieldChange($event)" + (onBlurField)="handleBlurField($event)" >
@@ -119,6 +120,25 @@ export class MultiFieldInput implements OnInit, OnChanges { @Input() properties?: Properties; @Output() onChange = new EventEmitter(); @Output() validityChange = new EventEmitter(); + /** + * Emits a field's name when it loses focus. Touched state is tracked + * internally either way; this is the hook for driving an external form store + * - pass `createDynamicFormStore`'s `handleBlur` to get its `touched` map and + * `validateOnBlur` behaviour. + */ + @Output() onBlurField = new EventEmitter(); + + private touchedFields: Record = {}; + + handleBlurField(fieldName: string): void { + this.touchedFields = { ...this.touchedFields, [fieldName]: true }; + this.onBlurField.emit(fieldName); + } + + /** Whether this field has been blurred at least once. */ + isTouched(fieldName: string): boolean { + return Boolean(this.touchedFields[fieldName]); + } @Input() layout: LayoutConfig = 'column'; // Top-level form data, threaded down through repeatable groups so a nested // field's appearCondition/computeValue can read the root form. Omitted at diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts new file mode 100644 index 0000000..8e9a769 --- /dev/null +++ b/packages/angular/src/lib/dynamic-form.store.ts @@ -0,0 +1,120 @@ +import { computed, signal } from '@angular/core'; +import { + applyComputedValues, + FieldDescription, + Properties, + validateFields, +} from '@dynamic-field-kit/core'; + +export interface DynamicFormOptions { + fields: FieldDescription[]; + initialValues?: Properties; + validateOnBlur?: boolean; + validateOnChange?: boolean; +} + +export function createDynamicFormStore(options: DynamicFormOptions) { + const fields = options.fields; + const initialValues = options.initialValues || {}; + const validateOnBlur = options.validateOnBlur ?? true; + const validateOnChange = options.validateOnChange ?? false; + + const data = signal(applyComputedValues(fields, initialValues)); + const errors = signal>({}); + const isDirty = signal(false); + const touched = signal>({}); + const isSubmitting = signal(false); + const isSubmitted = signal(false); + + const isValid = computed(() => Object.keys(errors()).length === 0); + + function validate(): boolean { + const res = validateFields(fields, data()); + errors.set(res.errors); + return res.valid; + } + + function handleChange(newData: Properties) { + const next = applyComputedValues(fields, newData); + data.set(next); + isDirty.set(true); + + if (validateOnChange) { + const res = validateFields(fields, next); + errors.set(res.errors); + } + } + + function setFieldValue(name: string, value: unknown) { + handleChange({ ...data(), [name]: value }); + } + + function setFieldTouched(name: string, isTouched = true) { + touched.set({ ...touched(), [name]: isTouched }); + } + + function handleBlur(fieldName: string) { + setFieldTouched(fieldName, true); + if (validateOnBlur) { + const res = validateFields(fields, data()); + errors.set(res.errors); + } + } + + function reset(newValues?: Properties) { + const seed = newValues ?? initialValues; + const next = applyComputedValues(fields, seed); + data.set(next); + errors.set({}); + isDirty.set(false); + touched.set({}); + isSubmitting.set(false); + isSubmitted.set(false); + } + + /** + * Returns a submit handler, mirroring the React and Vue `useDynamicForm` + * hooks. Bind it once and use it as the `(ngSubmit)` handler: + * `onSubmit = this.store.handleSubmit(data => ...)`. + */ + function handleSubmit( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) { + return async (e?: Event) => { + if (e && typeof e.preventDefault === 'function') { + e.preventDefault(); + } + isSubmitting.set(true); + try { + const res = validateFields(fields, data()); + errors.set(res.errors); + isSubmitted.set(true); + if (res.valid) { + await onValid(data()); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + isSubmitting.set(false); + } + }; + } + + return { + data, + errors, + isValid, + isDirty, + touched, + isSubmitting, + isSubmitted, + setFieldValue, + setFieldTouched, + handleChange, + handleBlur, + reset, + validate, + handleSubmit, + }; +} diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index b1ba52a..8882c3b 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -5,6 +5,8 @@ export * from './components/BaseInput'; export * from './components/DynamicInput'; export * from './components/FieldInput'; export * from './components/MultiFieldInput'; +export * from './components/DynamicFormDevTools'; +export * from './lib/dynamic-form.store'; // Layout export * from './layout'; diff --git a/packages/angular/test/DynamicFormDevTools.spec.ts b/packages/angular/test/DynamicFormDevTools.spec.ts new file mode 100644 index 0000000..e9f20b8 --- /dev/null +++ b/packages/angular/test/DynamicFormDevTools.spec.ts @@ -0,0 +1,133 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FieldDescription } from '@dynamic-field-kit/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { DynamicFormDevToolsComponent } from '../src/components/DynamicFormDevTools'; + +function text(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent || ''; +} + +function buttons( + fixture: ComponentFixture +): HTMLButtonElement[] { + return Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('button') + ); +} + +function open(fixture: ComponentFixture) { + buttons(fixture)[0].click(); + fixture.detectChanges(); +} + +function clickTab( + fixture: ComponentFixture, + label: string +) { + const tab = buttons(fixture).find((b) => + (b.textContent || '').trim().toLowerCase().startsWith(label) + ); + tab?.click(); + fixture.detectChanges(); +} + +describe('DynamicFormDevToolsComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicFormDevToolsComponent); + fixture.detectChanges(); + }); + + it('renders collapsed by default', () => { + expect(text(fixture)).toContain('🔍 DevTools'); + expect(text(fixture)).not.toContain('🛠️ Form DevTools'); + }); + + it('opens the overlay', () => { + open(fixture); + + expect(text(fixture)).toContain('🛠️ Form DevTools'); + }); + + it('shows no error badge when the form is clean', () => { + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.dfk-devtools-badge' + ) + ).toBeNull(); + }); + + it('shows the error count on the collapsed badge', () => { + // setInput, not a plain assignment: the component is OnPush, so only a real + // input binding marks it for check - which is what an app does. + fixture.componentRef.setInput('errors', { + name: ['required'], + email: ['invalid'], + }); + fixture.detectChanges(); + + const badge = (fixture.nativeElement as HTMLElement).querySelector( + '.dfk-devtools-badge' + ); + expect(badge?.textContent?.trim()).toBe('2'); + }); + + it('shows the error count in the errors tab label', () => { + fixture.componentInstance.errors = { name: ['required'] }; + open(fixture); + + const tab = buttons(fixture).find((b) => + (b.textContent || '').trim().toLowerCase().startsWith('errors') + ); + expect(tab?.textContent?.replace(/\s+/g, ' ').trim()).toBe('errors (1)'); + }); + + it('shows form data on the data tab', () => { + fixture.componentInstance.data = { email: 'a@b.com' }; + open(fixture); + + expect(text(fixture)).toContain('a@b.com'); + }); + + it('shows errors on the errors tab', () => { + fixture.componentInstance.errors = { email: ['Invalid email'] }; + open(fixture); + clickTab(fixture, 'errors'); + + expect(text(fixture)).toContain('Invalid email'); + }); + + it('shows dirty and touched state on the meta tab', () => { + fixture.componentInstance.isDirty = true; + fixture.componentInstance.touched = { email: true }; + open(fixture); + clickTab(fixture, 'meta'); + + expect(text(fixture)).toContain('isDirty: true'); + expect(text(fixture)).toContain('email'); + }); + + it('lists field descriptions on the fields tab', () => { + const fields: FieldDescription[] = [{ name: 'email', type: 'text' }]; + fixture.componentInstance.fields = fields; + open(fixture); + clickTab(fixture, 'fields'); + + expect(text(fixture)).toContain('email'); + expect(text(fixture)).toContain('type: text'); + }); + + it('closes the overlay again', () => { + open(fixture); + + const close = buttons(fixture).find( + (b) => (b.textContent || '').trim() === '✕' + ); + close?.click(); + fixture.detectChanges(); + + expect(text(fixture)).not.toContain('🛠️ Form DevTools'); + expect(text(fixture)).toContain('🔍 DevTools'); + }); +}); diff --git a/packages/angular/test/MultiFieldInputBlur.spec.ts b/packages/angular/test/MultiFieldInputBlur.spec.ts new file mode 100644 index 0000000..d6d1cf4 --- /dev/null +++ b/packages/angular/test/MultiFieldInputBlur.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FieldDescription } from '@dynamic-field-kit/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MultiFieldInput } from '../src/components/MultiFieldInput'; +import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; +import { makeRegistry, TextRendererComponent } from './helpers/renderers'; + +const fields: FieldDescription[] = [ + { name: 'first', type: 'text', label: 'First' }, + { name: 'second', type: 'text', label: 'Second' }, +]; + +describe('MultiFieldInput blur reporting', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + const registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', fields); + fixture.detectChanges(); + }); + + function inputs(): HTMLElement[] { + return Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('input') + ); + } + + it('reports which field was blurred', () => { + const seen: string[] = []; + fixture.componentInstance.onBlurField.subscribe((name: string) => + seen.push(name) + ); + + inputs()[1].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + + expect(seen).toEqual(['second']); + }); + + it('reports each field separately', () => { + const seen: string[] = []; + fixture.componentInstance.onBlurField.subscribe((name: string) => + seen.push(name) + ); + + inputs()[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + inputs()[1].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + + expect(seen).toEqual(['first', 'second']); + }); + + it('does not throw when nothing is subscribed', () => { + expect(() => { + inputs()[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('marks the blurred field touched', () => { + const spy = vi.fn(); + fixture.componentInstance.onBlurField.subscribe(spy); + + inputs()[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + + expect(fixture.componentInstance.isTouched('first')).toBe(true); + expect(fixture.componentInstance.isTouched('second')).toBe(false); + }); +}); diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts new file mode 100644 index 0000000..8916198 --- /dev/null +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -0,0 +1,144 @@ +import { FieldDescription } from '@dynamic-field-kit/core'; +import { describe, expect, it, vi } from 'vitest'; +import { createDynamicFormStore } from '../src/lib/dynamic-form.store'; + +const fields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + required: true, + validate: (v) => (v ? undefined : 'Username is required'), + }, +]; + +describe('Angular Signal DynamicFormStore', () => { + it('initializes signal data and handles changes', () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + + expect(store.data().username).toBe('john_doe'); + expect(store.isDirty()).toBe(false); + + store.setFieldValue('username', 'jane_doe'); + expect(store.data().username).toBe('jane_doe'); + expect(store.isDirty()).toBe(true); + + store.reset(); + expect(store.data().username).toBe('john_doe'); + expect(store.isDirty()).toBe(false); + }); + + it('returns a submit handler, matching the React and Vue hooks', async () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + const onValid = vi.fn(); + + const submit = store.handleSubmit(onValid); + expect(typeof submit).toBe('function'); + + await submit(); + + expect(onValid).toHaveBeenCalledWith({ username: 'john_doe' }); + expect(store.isSubmitted()).toBe(true); + expect(store.isSubmitting()).toBe(false); + }); + + it('calls preventDefault on the submitted event', async () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + const preventDefault = vi.fn(); + + await store.handleSubmit(vi.fn())({ preventDefault } as unknown as Event); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('routes validation failures to onInvalid', async () => { + const store = createDynamicFormStore({ fields }); + const onValid = vi.fn(); + const onInvalid = vi.fn(); + + await store.handleSubmit(onValid, onInvalid)(); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ + username: ['Username is required'], + }); + expect(store.isValid()).toBe(false); + }); + + it('clears isSubmitting when the submit handler throws', async () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + + await expect( + store.handleSubmit(() => { + throw new Error('boom'); + })() + ).rejects.toThrow('boom'); + + expect(store.isSubmitting()).toBe(false); + }); + + it('validates on blur and marks the field touched', () => { + const store = createDynamicFormStore({ fields }); + + store.handleBlur('username'); + + expect(store.touched().username).toBe(true); + expect(store.errors().username).toEqual(['Username is required']); + }); + + it('skips blur validation when validateOnBlur is false', () => { + const store = createDynamicFormStore({ fields, validateOnBlur: false }); + + store.handleBlur('username'); + + expect(store.touched().username).toBe(true); + expect(store.errors()).toEqual({}); + }); + + it('validates on change when validateOnChange is true', () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + validateOnChange: true, + }); + + store.setFieldValue('username', ''); + + expect(store.errors().username).toEqual(['Username is required']); + }); + + it('supports setFieldTouched and imperative validate', () => { + const store = createDynamicFormStore({ fields }); + + store.setFieldTouched('username'); + expect(store.touched().username).toBe(true); + store.setFieldTouched('username', false); + expect(store.touched().username).toBe(false); + + expect(store.validate()).toBe(false); + expect(store.errors().username).toEqual(['Username is required']); + }); + + it('resets to explicitly supplied values', () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + + store.reset({ username: 'ada' }); + + expect(store.data().username).toBe('ada'); + expect(store.isSubmitted()).toBe(false); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 3ba4227..478295b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,8 @@ "@vitest/coverage-istanbul": "^0.34.6", "rimraf": "^6.1.3", "tsup": "^8.0.1", - "vitest": "^0.34.0" + "vitest": "^0.34.0", + "yup": "^1.7.1", + "zod": "^4.4.3" } } diff --git a/packages/core/src/adapters.ts b/packages/core/src/adapters.ts new file mode 100644 index 0000000..967a4d1 --- /dev/null +++ b/packages/core/src/adapters.ts @@ -0,0 +1,237 @@ +import { Properties } from './types'; + +export type FieldValidatorResult = + | string + | string[] + | undefined + | Promise; + +export type FieldValidatorFunction = ( + value: unknown, + data: Properties, + rootData?: Properties +) => FieldValidatorResult; + +export interface SchemaValidatorOptions { + /** + * Name of the field this validator is attached to. Its live value is patched + * into the form data before parsing, and only issues whose path points at + * this field are surfaced. + */ + field?: string; + /** + * What the schema describes: + * - `'form'` (default) - an object schema covering the whole form; the form + * data object is parsed. + * - `'field'` - a scalar schema covering one value (e.g. `z.string().email()`); + * the field's value is parsed on its own. + */ + target?: 'form' | 'field'; +} + +/** Accepts the shorthand `zodValidator(schema, 'email')` or an options object. */ +function normalizeOptions( + fieldNameOrOptions?: string | SchemaValidatorOptions +): Required> & { field?: string } { + if (typeof fieldNameOrOptions === 'string') { + return { field: fieldNameOrOptions, target: 'form' }; + } + return { + field: fieldNameOrOptions?.field, + target: fieldNameOrOptions?.target ?? 'form', + }; +} + +/** + * Payload an adapter parses. + * + * For a `'form'` schema the form data object is parsed, with the field's live + * `value` patched in when a field name is known, so cross-field rules still see + * the rest of the form. For a `'field'` schema the value is parsed on its own. + */ +function buildPayload( + data: Properties, + value: unknown, + field: string | undefined, + target: 'form' | 'field' +): unknown { + if (target === 'field') { + return value; + } + return field ? { ...data, [field]: value } : data; +} + +/** Keeps only the issues belonging to `fieldName`, then maps them to messages. */ +function toMessages( + issues: any[], + fieldName: string | undefined, + matchPath: (issue: any, fieldName: string) => boolean +): string[] | undefined { + const matched = fieldName + ? issues.filter((issue) => matchPath(issue, fieldName)) + : issues; + return matched.length === 0 + ? undefined + : matched.map((issue: any) => issue.message); +} + +/** Zod/Standard-Schema issue paths are arrays: `['address', 'street']`. */ +function matchesArrayPath(issue: any, fieldName: string): boolean { + return Array.isArray(issue.path) && issue.path.includes(fieldName); +} + +/** Yup issue paths are strings: `'address.street'`. */ +function matchesStringPath(issue: any, fieldName: string): boolean { + return issue.path === fieldName; +} + +function zodResultToMessages( + res: any, + fieldName?: string +): string[] | undefined { + if (res.success) { + return undefined; + } + const issues = res.error?.issues || res.error?.errors || []; + return toMessages(issues, fieldName, matchesArrayPath); +} + +/** + * Validates whole form data or a specific field using a Zod schema. + * + * Parses synchronously so the result is usable by the synchronous + * `validateFields`. Schemas containing async refinements cannot be parsed + * synchronously - for those, a Promise is returned and you must validate via + * `validateFieldsAsync`. + */ +export function zodValidator( + schema: any, + fieldNameOrOptions?: string | SchemaValidatorOptions +): FieldValidatorFunction { + const { field, target } = normalizeOptions(fieldNameOrOptions); + // A scalar schema reports issues with an empty path, so there is nothing to + // filter by - every issue belongs to this field. + const filterBy = target === 'field' ? undefined : field; + + return (value: unknown, data: Properties) => { + const payload = buildPayload(data, value, field, target); + + if (typeof schema.safeParse === 'function') { + try { + return zodResultToMessages(schema.safeParse(payload), filterBy); + } catch { + // Zod throws when the schema needs async parsing; fall through. + } + } + + if (typeof schema.safeParseAsync === 'function') { + return schema + .safeParseAsync(payload) + .then((res: any) => zodResultToMessages(res, filterBy)); + } + + return undefined; + }; +} + +/** A Yup ValidationError, as opposed to Yup's "test returned a Promise" error. */ +function isYupValidationError(err: any): boolean { + return err?.name === 'ValidationError' || Array.isArray(err?.inner); +} + +function yupErrorToMessages( + err: any, + fieldName?: string +): string[] | undefined { + if (Array.isArray(err.inner) && err.inner.length > 0) { + return toMessages(err.inner, fieldName, matchesStringPath); + } + if (fieldName && err.path !== undefined && err.path !== fieldName) { + return undefined; + } + return err.message ? [err.message] : undefined; +} + +/** + * Validates whole form data or a specific field using a Yup schema. + * + * Validates synchronously so the result is usable by the synchronous + * `validateFields`. Schemas with async `.test()` rules cannot be validated + * synchronously - for those, a Promise is returned and you must validate via + * `validateFieldsAsync`. + */ +export function yupValidator( + schema: any, + fieldNameOrOptions?: string | SchemaValidatorOptions +): FieldValidatorFunction { + const { field, target } = normalizeOptions(fieldNameOrOptions); + const filterBy = target === 'field' ? undefined : field; + + return (value: unknown, data: Properties) => { + const payload = buildPayload(data, value, field, target); + + if (typeof schema.validateSync === 'function') { + try { + schema.validateSync(payload, { abortEarly: false }); + return undefined; + } catch (err: any) { + if (isYupValidationError(err)) { + return yupErrorToMessages(err, filterBy); + } + // Not a ValidationError: Yup throws a plain Error when a test is + // async. Retry asynchronously so that internal message never reaches + // the user - but only if there is an async path to retry on. + if (typeof schema.validate !== 'function') { + return yupErrorToMessages(err, filterBy); + } + } + } + + if (typeof schema.validate === 'function') { + return schema + .validate(payload, { abortEarly: false }) + .then(() => undefined) + .catch((err: any) => yupErrorToMessages(err, filterBy)); + } + + return undefined; + }; +} + +function standardResultToMessages( + result: any, + fieldName?: string +): string[] | undefined { + if (!result.issues || result.issues.length === 0) { + return undefined; + } + return toMessages(result.issues, fieldName, matchesArrayPath); +} + +/** + * Validates whole form data or a specific field using a Valibot or + * Standard-Schema compatible object. + */ +export function standardSchemaValidator( + schema: any, + fieldNameOrOptions?: string | SchemaValidatorOptions +): FieldValidatorFunction { + const { field, target } = normalizeOptions(fieldNameOrOptions); + const filterBy = target === 'field' ? undefined : field; + + return (value: unknown, data: Properties) => { + const payload = buildPayload(data, value, field, target); + const std = schema['~standard'] || schema; + + if (typeof std.validate !== 'function') { + return undefined; + } + + const result = std.validate(payload); + return result instanceof Promise + ? result.then((res: any) => standardResultToMessages(res, filterBy)) + : standardResultToMessages(result, filterBy); + }; +} + +export const valibotValidator = standardSchemaValidator; diff --git a/packages/core/src/fieldGroup.ts b/packages/core/src/fieldGroup.ts index 421cd6d..07cde7a 100644 --- a/packages/core/src/fieldGroup.ts +++ b/packages/core/src/fieldGroup.ts @@ -23,3 +23,73 @@ export function canRemoveGroupItem( ): boolean { return field.minItems === undefined || items.length > field.minItems; } + +export function moveGroupItem( + items: Properties[], + fromIndex: number, + toIndex: number +): Properties[] { + if ( + fromIndex < 0 || + fromIndex >= items.length || + toIndex < 0 || + toIndex >= items.length + ) { + return items; + } + const result = [...items]; + const [removed] = result.splice(fromIndex, 1); + result.splice(toIndex, 0, removed); + return result; +} + +export function swapGroupItems( + items: Properties[], + indexA: number, + indexB: number +): Properties[] { + if ( + indexA < 0 || + indexA >= items.length || + indexB < 0 || + indexB >= items.length + ) { + return items; + } + const result = [...items]; + const temp = result[indexA]; + result[indexA] = result[indexB]; + result[indexB] = temp; + return result; +} + +export function insertGroupItem( + items: Properties[], + index: number, + newItem: Properties = {} +): Properties[] { + const safeIndex = Math.max(0, Math.min(index, items.length)); + const result = [...items]; + result.splice(safeIndex, 0, newItem); + return result; +} + +export function focusFirstInvalidField( + containerOrForm?: HTMLElement | null +): boolean { + if (typeof document === 'undefined') { + return false; + } + const root = containerOrForm || document.body; + const invalidElement = root.querySelector( + '[aria-invalid="true"], input:invalid, select:invalid, textarea:invalid' + ); + if (invalidElement && typeof invalidElement.focus === 'function') { + invalidElement.focus(); + if (typeof invalidElement.scrollIntoView === 'function') { + invalidElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + return true; + } + return false; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cfe7dca..33d5702 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,4 +11,11 @@ export { createGroupItem, canAddGroupItem, canRemoveGroupItem, + moveGroupItem, + swapGroupItems, + insertGroupItem, + focusFirstInvalidField, } from './fieldGroup'; + +export * from './adapters'; +export * from './wizard'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 28cc8c6..f922530 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,13 @@ export interface FieldTypeMap { textarea: string; password: string; email: string; + radio: string | number; + range: number; + file: unknown; + date: string; + time: string; + 'datetime-local': string; + switch: boolean; } export type Properties = Record; @@ -33,6 +40,11 @@ export interface FieldRendererProps { ariaInvalid?: boolean; ariaDescribedBy?: string; ariaRequired?: boolean; + min?: number | string; + max?: number | string; + step?: number | string; + accept?: string; + multiple?: boolean; } export interface FieldDescription { @@ -76,6 +88,12 @@ export interface FieldDescription { options?: | Properties[] | ((data: Properties, rootData?: Properties) => Properties[]); + min?: number | string; + max?: number | string; + step?: number | string; + accept?: string; + multiple?: boolean; + debounceMs?: number; className?: string; description?: unknown; /** diff --git a/packages/core/src/wizard.ts b/packages/core/src/wizard.ts new file mode 100644 index 0000000..226686a --- /dev/null +++ b/packages/core/src/wizard.ts @@ -0,0 +1,135 @@ +import { FieldDescription, Properties } from './types'; +import { validateFields, ValidationResult } from './validation'; + +export interface FormStep { + id: string; + title: string; + description?: string; + fields: FieldDescription[]; +} + +export interface WizardState { + currentStepIndex: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + currentStep: FormStep; + steps: FormStep[]; + completedSteps: number[]; +} + +const EMPTY_STEP: FormStep = { id: '', title: '', fields: [] }; + +function clampIndex(index: number, stepCount: number): number { + return Math.max(0, Math.min(index, stepCount - 1)); +} + +export function createWizardState( + steps: FormStep[], + initialStepIndex = 0 +): WizardState { + const safeIndex = clampIndex(initialStepIndex, steps.length); + return { + currentStepIndex: safeIndex, + totalSteps: steps.length, + isFirstStep: safeIndex === 0, + isLastStep: safeIndex === steps.length - 1, + currentStep: steps[safeIndex] || EMPTY_STEP, + steps, + completedSteps: [], + }; +} + +/** Rebuilds the derived flags for a new position. Never mutates `state`. */ +function atStep( + state: WizardState, + index: number, + completedSteps: number[] +): WizardState { + const safeIndex = clampIndex(index, state.steps.length); + return { + ...state, + currentStepIndex: safeIndex, + isFirstStep: safeIndex === 0, + isLastStep: safeIndex === state.steps.length - 1, + currentStep: state.steps[safeIndex] || EMPTY_STEP, + completedSteps, + }; +} + +function withCompleted(completedSteps: number[], index: number): number[] { + if (completedSteps.includes(index)) { + return completedSteps; + } + return [...completedSteps, index].sort((a, b) => a - b); +} + +export function validateStep( + step: FormStep, + data: Properties +): ValidationResult { + return validateFields(step.fields, data); +} + +export function canGoNext(state: WizardState): boolean { + return state.currentStepIndex < state.totalSteps - 1; +} + +export function canGoPrev(state: WizardState): boolean { + return state.currentStepIndex > 0; +} + +/** Has this step already been left behind via `goNext` or marked complete? */ +export function isStepCompleted(state: WizardState, index: number): boolean { + return state.completedSteps.includes(index); +} + +/** + * Records a step as completed. Defaults to the current step. Returns the same + * state when the index is out of range or already recorded. + */ +export function markStepCompleted( + state: WizardState, + index: number = state.currentStepIndex +): WizardState { + if (index < 0 || index >= state.steps.length) { + return state; + } + const completedSteps = withCompleted(state.completedSteps, index); + if (completedSteps === state.completedSteps) { + return state; + } + return { ...state, completedSteps }; +} + +/** + * Moves to `index`, clamped to the available steps. Jumping around does not + * mark anything completed - only `goNext` and `markStepCompleted` do that. + */ +export function goToStep(state: WizardState, index: number): WizardState { + return atStep(state, index, state.completedSteps); +} + +/** + * Advances one step, recording the step being left as completed. Returns the + * same state when already on the last step, so callers can compare identity. + * Validate first with `validateStep` when the step must be valid to leave. + */ +export function goNext(state: WizardState): WizardState { + if (!canGoNext(state)) { + return state; + } + return atStep( + state, + state.currentStepIndex + 1, + withCompleted(state.completedSteps, state.currentStepIndex) + ); +} + +/** Goes back one step, keeping the completed set intact. */ +export function goPrev(state: WizardState): WizardState { + if (!canGoPrev(state)) { + return state; + } + return atStep(state, state.currentStepIndex - 1, state.completedSteps); +} diff --git a/packages/core/test/adapters.test.ts b/packages/core/test/adapters.test.ts new file mode 100644 index 0000000..511fcb9 --- /dev/null +++ b/packages/core/test/adapters.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + zodValidator, + yupValidator, + standardSchemaValidator, +} from '../src/adapters'; + +describe('Schema Adapters', () => { + it('handles zod schema validation', async () => { + const fakeZodSchema = { + safeParse: (data: any) => { + if (!data.email || !data.email.includes('@')) { + return { + success: false, + error: { + issues: [{ path: ['email'], message: 'Invalid email address' }], + }, + }; + } + return { success: true, data }; + }, + }; + + const validator = zodValidator(fakeZodSchema, 'email'); + const err = validator('invalid', { email: 'invalid' }); + expect(err).toEqual(['Invalid email address']); + + const valid = validator('test@example.com', { email: 'test@example.com' }); + expect(valid).toBeUndefined(); + }); + + it('handles yup schema validation', () => { + const fakeYupSchema = { + validateSync: (data: any) => { + if (!data.age || data.age < 18) { + const err: any = new Error('Validation failed'); + err.inner = [{ path: 'age', message: 'Must be at least 18' }]; + throw err; + } + }, + }; + + const validator = yupValidator(fakeYupSchema, 'age'); + const err = validator(15, { age: 15 }); + expect(err).toEqual(['Must be at least 18']); + + const valid = validator(20, { age: 20 }); + expect(valid).toBeUndefined(); + }); + + it('handles standard schema validation', () => { + const fakeStandardSchema = { + '~standard': { + validate: (data: any) => { + if (!data.name) { + return { + issues: [{ path: ['name'], message: 'Name is required' }], + }; + } + return { issues: [] }; + }, + }, + }; + + const validator = standardSchemaValidator(fakeStandardSchema, 'name'); + const err = validator('', { name: '' }); + expect(err).toEqual(['Name is required']); + + const valid = validator('John', { name: 'John' }); + expect(valid).toBeUndefined(); + }); +}); diff --git a/packages/core/test/adaptersRealSchemas.test.ts b/packages/core/test/adaptersRealSchemas.test.ts new file mode 100644 index 0000000..b9764bb --- /dev/null +++ b/packages/core/test/adaptersRealSchemas.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import * as yup from 'yup'; +import { z } from 'zod'; +import { + standardSchemaValidator, + yupValidator, + zodValidator, +} from '../src/adapters'; +import type { FieldDescription } from '../src/types'; +import { validateFields, validateFieldsAsync } from '../src/validation'; + +describe('zodValidator with a real Zod schema', () => { + const schema = z.object({ + email: z.string().email('Invalid email address'), + }); + + it('reports field errors through the synchronous validateFields path', () => { + const fields: FieldDescription[] = [ + { name: 'email', type: 'text', validate: zodValidator(schema, 'email') }, + ]; + + const res = validateFields(fields, { email: 'not-an-email' }); + + expect(res.valid).toBe(false); + expect(res.errors.email).toEqual(['Invalid email address']); + }); + + it('returns messages synchronously rather than a Promise', () => { + const result = zodValidator(schema, 'email')('nope', { email: 'nope' }); + + expect(result).not.toBeInstanceOf(Promise); + expect(result).toEqual(['Invalid email address']); + }); + + it('accepts valid data', () => { + const result = zodValidator(schema, 'email')('a@b.com', { + email: 'a@b.com', + }); + + expect(result).toBeUndefined(); + }); + + it('validates the whole form object when no fieldName is given', () => { + const validator = zodValidator(schema); + + // `value` is the field's own value; the schema describes the whole form, + // so the form data must be what gets parsed. + const result = validator('not-an-email', { email: 'not-an-email' }); + + expect(result).toEqual(['Invalid email address']); + }); + + it('falls back to async parsing for schemas with async refinements', async () => { + const asyncSchema = z.object({ + name: z.string().refine(async (v) => v.length > 2, 'Name is too short'), + }); + const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: zodValidator(asyncSchema, 'name'), + }, + ]; + + const res = await validateFieldsAsync(fields, { name: 'x' }); + + expect(res.valid).toBe(false); + expect(res.errors.name).toEqual(['Name is too short']); + }); +}); + +describe('yupValidator with a real Yup schema', () => { + const schema = yup.object({ + age: yup.number().min(18, 'Must be at least 18'), + }); + + it('reports field errors through the synchronous validateFields path', () => { + const fields: FieldDescription[] = [ + { name: 'age', type: 'number', validate: yupValidator(schema, 'age') }, + ]; + + const res = validateFields(fields, { age: 15 }); + + expect(res.valid).toBe(false); + expect(res.errors.age).toEqual(['Must be at least 18']); + }); + + it('accepts valid data', () => { + const result = yupValidator(schema, 'age')(20, { age: 20 }); + + expect(result).toBeUndefined(); + }); + + it('validates the whole form object when no fieldName is given', () => { + const result = yupValidator(schema)(15, { age: 15 }); + + expect(result).toEqual(['Must be at least 18']); + }); + + it('does not surface Yup internal async errors as validation messages', async () => { + const asyncSchema = yup.object({ + name: yup + .string() + .test('async-check', 'Name is taken', async (v) => v === 'free'), + }); + + const result = await yupValidator(asyncSchema, 'name')('taken', { + name: 'taken', + }); + + expect(result).toEqual(['Name is taken']); + }); + + it('falls back to async validation through validateFieldsAsync', async () => { + const asyncSchema = yup.object({ + name: yup + .string() + .test('async-check', 'Name is taken', async (v) => v === 'free'), + }); + const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: yupValidator(asyncSchema, 'name'), + }, + ]; + + const res = await validateFieldsAsync(fields, { name: 'taken' }); + + expect(res.errors.name).toEqual(['Name is taken']); + }); +}); + +describe('field-level (scalar) schemas via target: "field"', () => { + it('parses the field value alone with a scalar Zod schema', () => { + const validator = zodValidator(z.string().email('Invalid email address'), { + target: 'field', + }); + + expect(validator('nope', { email: 'nope' })).toEqual([ + 'Invalid email address', + ]); + expect(validator('a@b.com', { email: 'a@b.com' })).toBeUndefined(); + }); + + it('parses the field value alone with a scalar Yup schema', () => { + const validator = yupValidator(yup.string().min(3, 'Too short'), { + target: 'field', + }); + + expect(validator('hi', { name: 'hi' })).toEqual(['Too short']); + expect(validator('hello', { name: 'hello' })).toBeUndefined(); + }); + + it('parses the field value alone with a scalar Standard Schema', () => { + const validator = standardSchemaValidator(z.string().min(2, 'Too short'), { + target: 'field', + }); + + expect(validator('x', { city: 'x' })).toEqual(['Too short']); + expect(validator('xy', { city: 'xy' })).toBeUndefined(); + }); + + it('still accepts a plain field name string for form schemas', () => { + const schema = z.object({ email: z.string().email('Invalid email') }); + + expect(zodValidator(schema, 'email')('bad', { email: 'bad' })).toEqual([ + 'Invalid email', + ]); + expect( + zodValidator(schema, { field: 'email' })('bad', { email: 'bad' }) + ).toEqual(['Invalid email']); + }); +}); + +describe('standardSchemaValidator with a real Zod schema', () => { + const schema = z.object({ + city: z.string().min(2, 'City is too short'), + }); + + it('reports field errors through the synchronous validateFields path', () => { + const fields: FieldDescription[] = [ + { + name: 'city', + type: 'text', + validate: standardSchemaValidator(schema, 'city'), + }, + ]; + + const res = validateFields(fields, { city: 'x' }); + + expect(res.valid).toBe(false); + expect(res.errors.city).toEqual(['City is too short']); + }); + + it('validates the whole form object when no fieldName is given', () => { + const result = standardSchemaValidator(schema)('x', { city: 'x' }); + + expect(result).toEqual(['City is too short']); + }); +}); diff --git a/packages/core/test/fieldGroupArray.test.ts b/packages/core/test/fieldGroupArray.test.ts new file mode 100644 index 0000000..0420334 --- /dev/null +++ b/packages/core/test/fieldGroupArray.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'vitest'; +import { + insertGroupItem, + moveGroupItem, + swapGroupItems, +} from '../src/fieldGroup'; + +describe('moveGroupItem', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + test('moves an item forward', () => { + expect(moveGroupItem(items, 0, 2)).toEqual([ + { id: 'b' }, + { id: 'c' }, + { id: 'a' }, + ]); + }); + + test('moves an item backward', () => { + expect(moveGroupItem(items, 2, 0)).toEqual([ + { id: 'c' }, + { id: 'a' }, + { id: 'b' }, + ]); + }); + + test('does not mutate the original array', () => { + moveGroupItem(items, 0, 2); + expect(items).toEqual([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + }); + + test.each([ + ['negative from', -1, 1], + ['from past the end', 3, 1], + ['negative to', 0, -1], + ['to past the end', 0, 3], + ])('returns the array unchanged for %s', (_label, from, to) => { + expect(moveGroupItem(items, from, to)).toBe(items); + }); +}); + +describe('swapGroupItems', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + test('swaps two items', () => { + expect(swapGroupItems(items, 0, 2)).toEqual([ + { id: 'c' }, + { id: 'b' }, + { id: 'a' }, + ]); + }); + + test('does not mutate the original array', () => { + swapGroupItems(items, 0, 2); + expect(items).toEqual([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + }); + + test.each([ + ['negative index a', -1, 1], + ['index a past the end', 3, 1], + ['negative index b', 0, -1], + ['index b past the end', 0, 3], + ])('returns the array unchanged for %s', (_label, a, b) => { + expect(swapGroupItems(items, a, b)).toBe(items); + }); +}); + +describe('insertGroupItem', () => { + const items = [{ id: 'a' }, { id: 'b' }]; + + test('inserts at the given index', () => { + expect(insertGroupItem(items, 1, { id: 'x' })).toEqual([ + { id: 'a' }, + { id: 'x' }, + { id: 'b' }, + ]); + }); + + test('defaults to an empty item', () => { + expect(insertGroupItem(items, 0)).toEqual([{}, { id: 'a' }, { id: 'b' }]); + }); + + test('clamps a negative index to the start', () => { + expect(insertGroupItem(items, -5, { id: 'x' })[0]).toEqual({ id: 'x' }); + }); + + test('clamps an index past the end to the end', () => { + const result = insertGroupItem(items, 99, { id: 'x' }); + expect(result[result.length - 1]).toEqual({ id: 'x' }); + }); + + test('does not mutate the original array', () => { + insertGroupItem(items, 1, { id: 'x' }); + expect(items).toEqual([{ id: 'a' }, { id: 'b' }]); + }); +}); diff --git a/packages/core/test/focusFirstInvalidField.test.ts b/packages/core/test/focusFirstInvalidField.test.ts new file mode 100644 index 0000000..a9896a9 --- /dev/null +++ b/packages/core/test/focusFirstInvalidField.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { focusFirstInvalidField } from '../src/fieldGroup'; + +describe('focusFirstInvalidField', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('focuses the first element flagged aria-invalid', () => { + document.body.innerHTML = ` + + + + `; + + expect(focusFirstInvalidField()).toBe(true); + expect(document.activeElement?.id).toBe('bad'); + }); + + test('searches only inside the given container', () => { + document.body.innerHTML = ` +
+
+ `; + const container = document.getElementById('inside') as HTMLElement; + + expect(focusFirstInvalidField(container)).toBe(true); + expect(document.activeElement?.id).toBe('insideBad'); + }); + + test('returns false when nothing is invalid', () => { + document.body.innerHTML = ''; + + expect(focusFirstInvalidField()).toBe(false); + }); + + test('scrolls the focused field into view when supported', () => { + document.body.innerHTML = ''; + const field = document.getElementById('bad') as HTMLElement; + field.scrollIntoView = vi.fn(); + + focusFirstInvalidField(); + + expect(field.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + + test('falls back to document.body when given null', () => { + document.body.innerHTML = ''; + + expect(focusFirstInvalidField(null)).toBe(true); + expect(document.activeElement?.id).toBe('bad'); + }); +}); diff --git a/packages/core/test/types.test-d.ts b/packages/core/test/types.test-d.ts index b8cf349..8211660 100644 --- a/packages/core/test/types.test-d.ts +++ b/packages/core/test/types.test-d.ts @@ -112,6 +112,26 @@ test('condition hooks return booleans', () => { >().returns.toEqualTypeOf(); }); +test('every built-in default renderer key is a usable field type', () => { + // These are the keys the react/vue `defaultRenderersMap` registers. A type + // that has a shipped renderer but no FieldTypeMap entry cannot be used from + // TypeScript at all. + assertType({ name: 'a', type: 'text' }); + assertType({ name: 'b', type: 'number' }); + assertType({ name: 'c', type: 'password' }); + assertType({ name: 'd', type: 'email' }); + assertType({ name: 'e', type: 'textarea' }); + assertType({ name: 'f', type: 'checkbox' }); + assertType({ name: 'g', type: 'select' }); + assertType({ name: 'h', type: 'radio' }); + assertType({ name: 'i', type: 'range' }); + assertType({ name: 'j', type: 'file' }); + assertType({ name: 'k', type: 'date' }); + assertType({ name: 'l', type: 'time' }); + assertType({ name: 'm', type: 'datetime-local' }); + assertType({ name: 'n', type: 'switch' }); +}); + test('FieldTypeMap augmentation resolves', () => { expectTypeOf().toEqualTypeOf<{ id: string }>(); assertType({ name: 'c', type: 'customType' }); diff --git a/packages/core/test/validation.test.ts b/packages/core/test/validation.test.ts index 1fe1044..fa02af6 100644 --- a/packages/core/test/validation.test.ts +++ b/packages/core/test/validation.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'vitest'; +import type { FieldDescription } from '../src'; +import { zodValidator, yupValidator } from '../src/adapters'; import { resolveDisabled, resolveOptions, @@ -7,7 +9,6 @@ import { validateFields, validateFieldsAsync, } from '../src/validation'; -import type { FieldDescription } from '../src'; declare module '../src' { interface FieldTypeMap { @@ -203,3 +204,38 @@ describe('validateFieldsAsync', () => { expect(resOk.valid).toBe(true); }); }); + +describe('zodValidator and yupValidator', () => { + test('zodValidator parses the value alone for a scalar schema', () => { + const mockZod = { + safeParse: (val: unknown) => { + if (typeof val === 'string' && val.includes('@')) { + return { success: true }; + } + return { + success: false, + error: { + errors: [{ message: 'Invalid email address', path: [] }], + }, + }; + }, + }; + const validator = zodValidator(mockZod, { target: 'field' }); + expect(validator('invalid', {})).toEqual(['Invalid email address']); + expect(validator('user@test.com', {})).toBeUndefined(); + }); + + test('yupValidator parses the value alone for a scalar schema', () => { + const mockYup = { + validateSync: (val: unknown) => { + if (typeof val === 'string' && val.length >= 3) { + return val; + } + throw { message: 'Must be at least 3 chars' }; + }, + }; + const validator = yupValidator(mockYup, { target: 'field' }); + expect(validator('hi', {})).toEqual(['Must be at least 3 chars']); + expect(validator('hello', {})).toBeUndefined(); + }); +}); diff --git a/packages/core/test/wizard.test.ts b/packages/core/test/wizard.test.ts new file mode 100644 index 0000000..42bd40f --- /dev/null +++ b/packages/core/test/wizard.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { + createWizardState, + validateStep, + canGoNext, + canGoPrev, + goNext, + goPrev, + goToStep, + isStepCompleted, + markStepCompleted, + FormStep, +} from '../src/wizard'; + +describe('Wizard Core Module', () => { + const steps: FormStep[] = [ + { + id: 'step1', + title: 'Personal Info', + fields: [ + { + name: 'name', + type: 'text', + required: true, + validate: (val) => (!val ? 'Name is required' : undefined), + }, + ], + }, + { + id: 'step2', + title: 'Account Settings', + fields: [ + { + name: 'email', + type: 'email', + required: true, + validate: (val) => (!val ? 'Email is required' : undefined), + }, + ], + }, + ]; + + it('creates wizard state properly', () => { + const state = createWizardState(steps, 0); + expect(state.currentStepIndex).toBe(0); + expect(state.isFirstStep).toBe(true); + expect(state.isLastStep).toBe(false); + expect(state.currentStep.id).toBe('step1'); + }); + + it('validates step correctly', () => { + const step1 = steps[0]; + const invalidRes = validateStep(step1, {}); + expect(invalidRes.valid).toBe(false); + expect(invalidRes.errors.name).toEqual(['Name is required']); + + const validRes = validateStep(step1, { name: 'Alice' }); + expect(validRes.valid).toBe(true); + }); + + it('navigates through steps correctly', () => { + const state0 = createWizardState(steps, 0); + expect(canGoNext(state0)).toBe(true); + expect(canGoPrev(state0)).toBe(false); + + const state1 = createWizardState(steps, 1); + expect(canGoNext(state1)).toBe(false); + expect(canGoPrev(state1)).toBe(true); + }); + + it('defaults to the first step', () => { + const state = createWizardState(steps); + expect(state.currentStepIndex).toBe(0); + expect(state.currentStep.id).toBe('step1'); + expect(state.completedSteps).toEqual([]); + }); + + it('clamps an initial index past the last step', () => { + const state = createWizardState(steps, 99); + expect(state.currentStepIndex).toBe(1); + expect(state.isLastStep).toBe(true); + expect(state.isFirstStep).toBe(false); + }); + + it('clamps a negative initial index to the first step', () => { + const state = createWizardState(steps, -5); + expect(state.currentStepIndex).toBe(0); + expect(state.isFirstStep).toBe(true); + }); + + it('stays usable when there are no steps', () => { + const state = createWizardState([], 0); + expect(state.totalSteps).toBe(0); + expect(state.currentStep).toEqual({ id: '', title: '', fields: [] }); + expect(canGoNext(state)).toBe(false); + expect(canGoPrev(state)).toBe(false); + }); + + it('reports a step with no fields as valid', () => { + expect(validateStep({ id: 's', title: 'S', fields: [] }, {}).valid).toBe( + true + ); + }); +}); + +describe('Wizard navigation', () => { + const threeSteps: FormStep[] = [ + { id: 'a', title: 'A', fields: [] }, + { id: 'b', title: 'B', fields: [] }, + { id: 'c', title: 'C', fields: [] }, + ]; + + it('advances to the next step', () => { + const next = goNext(createWizardState(threeSteps)); + + expect(next.currentStepIndex).toBe(1); + expect(next.currentStep.id).toBe('b'); + expect(next.isFirstStep).toBe(false); + expect(next.isLastStep).toBe(false); + }); + + it('marks the step it leaves as completed', () => { + const next = goNext(createWizardState(threeSteps)); + + expect(next.completedSteps).toEqual([0]); + expect(isStepCompleted(next, 0)).toBe(true); + expect(isStepCompleted(next, 1)).toBe(false); + }); + + it('accumulates completed steps across advances', () => { + const state = goNext(goNext(createWizardState(threeSteps))); + + expect(state.currentStepIndex).toBe(2); + expect(state.isLastStep).toBe(true); + expect(state.completedSteps).toEqual([0, 1]); + }); + + it('does not advance past the last step', () => { + const last = createWizardState(threeSteps, 2); + + expect(goNext(last)).toBe(last); + }); + + it('goes back a step', () => { + const state = goPrev(goNext(createWizardState(threeSteps))); + + expect(state.currentStepIndex).toBe(0); + expect(state.isFirstStep).toBe(true); + }); + + it('keeps completed steps when going back', () => { + const state = goPrev(goNext(createWizardState(threeSteps))); + + expect(state.completedSteps).toEqual([0]); + }); + + it('does not go back past the first step', () => { + const first = createWizardState(threeSteps); + + expect(goPrev(first)).toBe(first); + }); + + it('jumps to an arbitrary step without marking it completed', () => { + const state = goToStep(createWizardState(threeSteps), 2); + + expect(state.currentStepIndex).toBe(2); + expect(state.currentStep.id).toBe('c'); + expect(state.completedSteps).toEqual([]); + }); + + it('clamps a jump to the available range', () => { + const state = createWizardState(threeSteps); + + expect(goToStep(state, 99).currentStepIndex).toBe(2); + expect(goToStep(state, -5).currentStepIndex).toBe(0); + }); + + it('marks the current step completed on demand', () => { + const state = markStepCompleted(createWizardState(threeSteps)); + + expect(state.completedSteps).toEqual([0]); + expect(state.currentStepIndex).toBe(0); + }); + + it('marks an explicit step completed, without duplicates', () => { + let state = markStepCompleted(createWizardState(threeSteps), 2); + state = markStepCompleted(state, 2); + + expect(state.completedSteps).toEqual([2]); + }); + + it('keeps completed steps in order', () => { + let state = markStepCompleted(createWizardState(threeSteps), 2); + state = markStepCompleted(state, 0); + + expect(state.completedSteps).toEqual([0, 2]); + }); + + it('ignores a completion mark outside the range', () => { + const state = createWizardState(threeSteps); + + expect(markStepCompleted(state, 99)).toBe(state); + expect(markStepCompleted(state, -1)).toBe(state); + }); + + it('does not mutate the state it is given', () => { + const state = createWizardState(threeSteps); + goNext(state); + + expect(state.currentStepIndex).toBe(0); + expect(state.completedSteps).toEqual([]); + }); + + it('stays usable with no steps', () => { + const empty = createWizardState([]); + + expect(goNext(empty)).toBe(empty); + expect(goPrev(empty)).toBe(empty); + expect(goToStep(empty, 3).currentStepIndex).toBe(0); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index 572bdae..40867a9 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -19,6 +19,8 @@ Note: `@dynamic-field-kit/core`, `react`, and `react-dom` are **peer dependencie - `DynamicInput` - `FieldInput` - `MultiFieldInput` +- `useDynamicForm` +- `DynamicFormDevTools` - `layoutRegistry` - `fieldRegistry` - `FieldRegistry` (class, for scoped registries) diff --git a/packages/react/src/components/DynamicFormDevTools.tsx b/packages/react/src/components/DynamicFormDevTools.tsx new file mode 100644 index 0000000..b9cb010 --- /dev/null +++ b/packages/react/src/components/DynamicFormDevTools.tsx @@ -0,0 +1,236 @@ +import { FieldDescription, Properties } from '@dynamic-field-kit/core'; +import React, { useState } from 'react'; + +export interface DynamicFormDevToolsProps { + data: Properties; + errors?: Record; + touched?: Record; + isDirty?: boolean; + fields?: FieldDescription[]; + position?: 'bottom-right' | 'bottom-left'; +} + +export const DynamicFormDevTools: React.FC = ({ + data, + errors = {}, + touched = {}, + isDirty = false, + fields = [], + position = 'bottom-right', +}) => { + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState< + 'data' | 'errors' | 'meta' | 'fields' + >('data'); + + const errorCount = Object.keys(errors).length; + + const posStyle: React.CSSProperties = + position === 'bottom-left' + ? { left: '16px', bottom: '16px' } + : { right: '16px', bottom: '16px' }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ + 🛠️ Form DevTools + + +
+ +
+ {(['data', 'errors', 'meta', 'fields'] as const).map((tab) => ( + + ))} +
+ +
+ {activeTab === 'data' && ( +
+            {JSON.stringify(data, null, 2)}
+          
+ )} + + {activeTab === 'errors' && ( +
+ {Object.keys(errors).length === 0 ? ( + ✓ No validation errors + ) : ( + Object.entries(errors).map(([field, msgs]) => ( +
+ + {field}: + +
    + {msgs.map((m, i) => ( +
  • + {m} +
  • + ))} +
+
+ )) + )} +
+ )} + + {activeTab === 'meta' && ( +
+
+ isDirty: + + {String(isDirty)} + +
+
+ Touched Fields: +
+                {JSON.stringify(touched, null, 2)}
+              
+
+
+ )} + + {activeTab === 'fields' && ( +
+ {fields.length === 0 ? ( + + No field descriptions passed + + ) : ( + fields.map((f) => ( +
+
+ {f.name} +
+
+ type: {f.type} | required: {String(Boolean(f.required))} +
+
+ )) + )} +
+ )} +
+
+ ); +}; diff --git a/packages/react/src/components/MultiFieldInput.tsx b/packages/react/src/components/MultiFieldInput.tsx index ec2baf7..73d3a66 100644 --- a/packages/react/src/components/MultiFieldInput.tsx +++ b/packages/react/src/components/MultiFieldInput.tsx @@ -33,6 +33,13 @@ interface Props { * included). */ onValidityChange?: (result: ValidationResult) => void; + /** + * Called with a field's name when it loses focus. Touched state is still + * tracked internally either way; this is the hook for driving an external + * form store - pass `useDynamicForm`'s `handleBlur` to get its `touched` + * map and `validateOnBlur` behaviour. + */ + onBlurField?: (fieldName: string) => void; } function resolveLayout(layout?: LayoutConfig) { @@ -52,6 +59,7 @@ const MultiFieldInput = ({ layout, rootData, onValidityChange, + onBlurField, }: Props) => { const [data, setData] = useState({}); const [touchedFields, setTouchedFields] = useState>( @@ -92,6 +100,8 @@ const MultiFieldInput = ({ rootDataRef.current = rootData; const onValidityChangeRef = useRef(onValidityChange); onValidityChangeRef.current = onValidityChange; + const onBlurFieldRef = useRef(onBlurField); + onBlurFieldRef.current = onBlurField; useEffect(() => { onValidityChangeRef.current?.( @@ -113,6 +123,7 @@ const MultiFieldInput = ({ const handleBlurField = useCallback((key: string) => { setTouchedFields((prev) => (prev[key] ? prev : { ...prev, [key]: true })); + onBlurFieldRef.current?.(key); }, []); const { type, config } = resolveLayout(layout); diff --git a/packages/react/src/defaultRenderers.tsx b/packages/react/src/defaultRenderers.tsx index 3fa18f8..67c2370 100644 --- a/packages/react/src/defaultRenderers.tsx +++ b/packages/react/src/defaultRenderers.tsx @@ -179,6 +179,136 @@ export const DefaultSelectRenderer: React.FC = ({ ); +export const DefaultRadioRenderer: React.FC = ({ + value, + onValueChange, + onBlur, + disabled, + readOnly, + required, + options = [], + id, + className, + ariaInvalid, + ariaDescribedBy, +}) => ( +
+ {options.map((opt, i) => { + const optVal = opt.value ?? opt.id ?? opt; + const optLabel = opt.label ?? opt.name ?? String(optVal); + const isChecked = String(value) === String(optVal); + const radioId = `${id || 'radio'}-${i}`; + return ( + + ); + })} +
+); + +export const DefaultRangeRenderer: React.FC = ({ + value, + onValueChange, + onBlur, + disabled, + readOnly, + required, + min, + max, + step, + id, + className, + ariaInvalid, + ariaDescribedBy, +}) => ( + onValueChange?.(Number(e.target.value))} + onBlur={onBlur} + disabled={disabled || readOnly} + required={required} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} + /> +); + +export const DefaultFileRenderer: React.FC = ({ + onValueChange, + onBlur, + disabled, + readOnly, + required, + accept, + multiple, + id, + className, + ariaInvalid, + ariaDescribedBy, +}) => ( + { + const files = e.target.files; + if (!files) { + return; + } + onValueChange?.(multiple ? Array.from(files) : files[0] || null); + }} + onBlur={onBlur} + disabled={disabled || readOnly} + required={required} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} + /> +); + +export const DefaultDateRenderer: React.FC = (props) => ( + +); + +export const DefaultTimeRenderer: React.FC = (props) => ( + +); + +export const DefaultDateTimeLocalRenderer: React.FC = ( + props +) => ; + +export const DefaultSwitchRenderer: React.FC = (props) => ( + +); + export const defaultRenderersMap: Record< string, React.FC @@ -190,6 +320,13 @@ export const defaultRenderersMap: Record< textarea: DefaultTextareaRenderer, checkbox: DefaultCheckboxRenderer, select: DefaultSelectRenderer, + radio: DefaultRadioRenderer, + range: DefaultRangeRenderer, + file: DefaultFileRenderer, + date: DefaultDateRenderer, + time: DefaultTimeRenderer, + 'datetime-local': DefaultDateTimeLocalRenderer, + switch: DefaultSwitchRenderer, }; export function getDefaultRenderer( diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1a4a8db..224eb6d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,7 +6,10 @@ export { layoutRegistry } from './layout'; export { default as DynamicInput } from './components/DynamicInput'; export { default as FieldInput } from './components/FieldInput'; export { default as MultiFieldInput } from './components/MultiFieldInput'; +export { DynamicFormDevTools } from './components/DynamicFormDevTools'; +export { useDynamicForm } from './useDynamicForm'; export { defaultRenderersMap, getDefaultRenderer } from './defaultRenderers'; + export { fieldRegistry, type ReactFieldRenderer, diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts new file mode 100644 index 0000000..bdfe42c --- /dev/null +++ b/packages/react/src/useDynamicForm.ts @@ -0,0 +1,153 @@ +import { + applyComputedValues, + FieldDescription, + Properties, + validateFields, +} from '@dynamic-field-kit/core'; +import React, { useCallback, useState } from 'react'; + +export interface UseDynamicFormOptions { + fields: FieldDescription[]; + initialValues?: Properties; + validateOnBlur?: boolean; + validateOnChange?: boolean; +} + +export interface UseDynamicFormResult { + data: Properties; + errors: Record; + isValid: boolean; + isDirty: boolean; + isSubmitting: boolean; + isSubmitted: boolean; + touched: Record; + setData: React.Dispatch>; + setFieldValue: (name: string, value: unknown) => void; + setFieldTouched: (name: string, isTouched?: boolean) => void; + handleChange: (newData: Properties) => void; + handleBlur: (fieldName: string) => void; + reset: (newValues?: Properties) => void; + validate: () => boolean; + handleSubmit: ( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) => (e?: React.FormEvent) => Promise; +} + +export function useDynamicForm({ + fields, + initialValues = {}, + validateOnBlur = true, + validateOnChange = false, +}: UseDynamicFormOptions): UseDynamicFormResult { + const [data, setData] = useState(() => + applyComputedValues(fields, initialValues) + ); + const [errors, setErrors] = useState>({}); + const [isDirty, setIsDirty] = useState(false); + const [touched, setTouched] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + + const validate = useCallback(() => { + const res = validateFields(fields, data); + setErrors(res.errors); + return res.valid; + }, [fields, data]); + + const handleChange = useCallback( + (newData: Properties) => { + const next = applyComputedValues(fields, newData); + setData(next); + setIsDirty(true); + + if (validateOnChange) { + const res = validateFields(fields, next); + setErrors(res.errors); + } + }, + [fields, validateOnChange] + ); + + const setFieldValue = useCallback( + (name: string, value: unknown) => { + handleChange({ ...data, [name]: value }); + }, + [data, handleChange] + ); + + const setFieldTouched = useCallback((name: string, isTouched = true) => { + setTouched((prev) => ({ ...prev, [name]: isTouched })); + }, []); + + const handleBlur = useCallback( + (fieldName: string) => { + setFieldTouched(fieldName, true); + if (validateOnBlur) { + const res = validateFields(fields, data); + setErrors(res.errors); + } + }, + [fields, data, validateOnBlur, setFieldTouched] + ); + + const reset = useCallback( + (newValues?: Properties) => { + const seed = newValues ?? initialValues; + const next = applyComputedValues(fields, seed); + setData(next); + setErrors({}); + setIsDirty(false); + setTouched({}); + setIsSubmitting(false); + setIsSubmitted(false); + }, + [fields, initialValues] + ); + + const handleSubmit = useCallback( + ( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) => + async (e?: React.FormEvent) => { + if (e && typeof e.preventDefault === 'function') { + e.preventDefault(); + } + setIsSubmitting(true); + try { + const res = validateFields(fields, data); + setErrors(res.errors); + setIsSubmitted(true); + if (res.valid) { + await onValid(data); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + setIsSubmitting(false); + } + }, + [fields, data] + ); + + const isValid = Object.keys(errors).length === 0; + + return { + data, + errors, + isValid, + isDirty, + isSubmitting, + isSubmitted, + touched, + setData, + setFieldValue, + setFieldTouched, + handleChange, + handleBlur, + reset, + validate, + handleSubmit, + }; +} diff --git a/packages/react/test/DynamicFormDevTools.test.tsx b/packages/react/test/DynamicFormDevTools.test.tsx new file mode 100644 index 0000000..a00373f --- /dev/null +++ b/packages/react/test/DynamicFormDevTools.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import type { FieldDescription } from '../src'; +import { DynamicFormDevTools } from '../src'; + +function open() { + fireEvent.click(screen.getByText('🔍 DevTools')); +} + +describe('DynamicFormDevTools', () => { + it('renders collapsed with no error badge when there are no errors', () => { + render(); + + expect(screen.getByText('🔍 DevTools')).toBeInTheDocument(); + expect(screen.queryByText('🛠️ Form DevTools')).not.toBeInTheDocument(); + }); + + it('shows the error count on the collapsed badge', () => { + render( + + ); + + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('shows form data on the data tab', () => { + render(); + open(); + + expect(screen.getByText(/a@b\.com/)).toBeInTheDocument(); + }); + + it('lists errors on the errors tab', () => { + render( + + ); + open(); + fireEvent.click(screen.getByRole('button', { name: /errors/i })); + + expect(screen.getByText('email:')).toBeInTheDocument(); + expect(screen.getByText('Invalid email')).toBeInTheDocument(); + expect(screen.getByText('Too short')).toBeInTheDocument(); + }); + + it('reports a clean form on the errors tab', () => { + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /errors/i })); + + expect(screen.getByText('✓ No validation errors')).toBeInTheDocument(); + }); + + it('shows dirty and touched state on the meta tab', () => { + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /meta/i })); + + expect(screen.getByText('isDirty:')).toBeInTheDocument(); + expect(screen.getByText('true')).toBeInTheDocument(); + expect(screen.getByText(/"email": true/)).toBeInTheDocument(); + }); + + it('lists field descriptions on the fields tab', () => { + const fields: FieldDescription[] = [ + { name: 'email', type: 'text', required: true }, + ]; + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /fields/i })); + + expect(screen.getByText('email')).toBeInTheDocument(); + expect( + screen.getByText(/type: text \| required: true/) + ).toBeInTheDocument(); + }); + + it('explains when no field descriptions were passed', () => { + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /fields/i })); + + expect( + screen.getByText('No field descriptions passed') + ).toBeInTheDocument(); + }); + + it('shows the error count in the errors tab label', () => { + render(); + open(); + + expect( + screen.getByRole('button', { name: /errors \(1\)/i }) + ).toBeInTheDocument(); + }); + + it('closes the overlay again', () => { + render(); + open(); + + fireEvent.click(screen.getByText('✕')); + + expect(screen.queryByText('🛠️ Form DevTools')).not.toBeInTheDocument(); + expect(screen.getByText('🔍 DevTools')).toBeInTheDocument(); + }); + + it('anchors to the bottom left when asked', () => { + const { container } = render( + + ); + + expect(container.querySelector('button')).toHaveStyle({ left: '16px' }); + }); +}); diff --git a/packages/react/test/MultiFieldInputBlur.test.tsx b/packages/react/test/MultiFieldInputBlur.test.tsx new file mode 100644 index 0000000..c32b6f1 --- /dev/null +++ b/packages/react/test/MultiFieldInputBlur.test.tsx @@ -0,0 +1,64 @@ +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import MultiFieldInput from '../src/components/MultiFieldInput'; +import { fieldRegistry } from '../src/fieldRegistry'; +import '../src/layout/defaultLayouts'; + +declare module '@dynamic-field-kit/core' { + interface FieldTypeMap { + text: string; + } +} + +const fields: FieldDescription[] = [ + { name: 'first', type: 'text', label: 'First' }, + { name: 'second', type: 'text', label: 'Second' }, +]; + +describe('MultiFieldInput blur reporting', () => { + beforeEach(() => { + fieldRegistry.register('text', ({ value, onValueChange, onBlur, id }) => ( + onValueChange?.(e.target.value)} + onBlur={onBlur} + /> + )); + }); + + it('reports which field was blurred', () => { + const onBlurField = vi.fn(); + render( + + ); + + fireEvent.blur(screen.getByTestId('dfk-field-second')); + + expect(onBlurField).toHaveBeenCalledWith('second'); + }); + + it('reports each field separately', () => { + const onBlurField = vi.fn(); + render( + + ); + + fireEvent.blur(screen.getByTestId('dfk-field-first')); + fireEvent.blur(screen.getByTestId('dfk-field-second')); + + expect(onBlurField.mock.calls.map(([name]) => name)).toEqual([ + 'first', + 'second', + ]); + }); + + it('still tracks touched internally when no handler is passed', () => { + expect(() => { + render(); + fireEvent.blur(screen.getByTestId('dfk-field-first')); + }).not.toThrow(); + }); +}); diff --git a/packages/react/test/defaultRenderersExtra.test.tsx b/packages/react/test/defaultRenderersExtra.test.tsx new file mode 100644 index 0000000..3292f7f --- /dev/null +++ b/packages/react/test/defaultRenderersExtra.test.tsx @@ -0,0 +1,174 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + DefaultDateRenderer, + DefaultDateTimeLocalRenderer, + DefaultFileRenderer, + DefaultRadioRenderer, + DefaultRangeRenderer, + DefaultSelectRenderer, + DefaultSwitchRenderer, + DefaultTimeRenderer, + defaultRenderersMap, + getDefaultRenderer, +} from '../src/defaultRenderers'; + +describe('date/time renderers', () => { + it.each([ + ['date', DefaultDateRenderer], + ['time', DefaultTimeRenderer], + ['datetime-local', DefaultDateTimeLocalRenderer], + ])('renders an input of type %s', (type, Renderer) => { + const { container } = render(); + + expect(container.querySelector('input')).toHaveAttribute('type', type); + }); + + it('emits the raw string value on change', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + fireEvent.change(container.querySelector('input')!, { + target: { value: '10:30' }, + }); + + expect(onValueChange).toHaveBeenCalledWith('10:30'); + }); +}); + +describe('DefaultSwitchRenderer', () => { + it('renders a checkbox reflecting the value', () => { + const { container } = render(); + + const input = container.querySelector('input')!; + expect(input).toHaveAttribute('type', 'checkbox'); + expect(input).toBeChecked(); + }); + + it('emits the checked state', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + fireEvent.click(container.querySelector('input')!); + + expect(onValueChange).toHaveBeenCalledWith(true); + }); +}); + +describe('DefaultFileRenderer', () => { + function fileInput(props: Record = {}) { + const { container } = render(); + return container.querySelector('input')!; + } + + it('emits a single File by default', () => { + const onValueChange = vi.fn(); + const input = fileInput({ onValueChange }); + const file = new File(['x'], 'a.txt', { type: 'text/plain' }); + + fireEvent.change(input, { target: { files: [file] } }); + + expect(onValueChange).toHaveBeenCalledWith(file); + }); + + it('emits an array when multiple is set', () => { + const onValueChange = vi.fn(); + const input = fileInput({ onValueChange, multiple: true }); + const a = new File(['a'], 'a.txt'); + const b = new File(['b'], 'b.txt'); + + fireEvent.change(input, { target: { files: [a, b] } }); + + expect(onValueChange).toHaveBeenCalledWith([a, b]); + }); + + it('emits null when a single selection is cleared', () => { + const onValueChange = vi.fn(); + const input = fileInput({ onValueChange }); + + fireEvent.change(input, { target: { files: [] } }); + + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it('forwards accept and multiple', () => { + const input = fileInput({ accept: '.png', multiple: true }); + + expect(input).toHaveAttribute('accept', '.png'); + expect(input).toHaveAttribute('multiple'); + }); +}); + +describe('option-shaped fallbacks', () => { + it('falls back to id/name when a select option has no value/label', () => { + render( + + ); + + expect(screen.getByRole('option', { name: 'One' })).toHaveValue('1'); + }); + + it('falls back to id/name when a radio option has no value/label', () => { + render( + + ); + + expect(screen.getByLabelText('One')).toBeChecked(); + }); + + it('treats a readOnly select as disabled', () => { + render(); + + expect(screen.getByRole('combobox')).toBeDisabled(); + }); +}); + +describe('DefaultRangeRenderer', () => { + it('falls back to min when no value is set', () => { + render(); + + expect(screen.getByRole('slider')).toHaveValue('5'); + }); + + it('emits a number on change', () => { + const onValueChange = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByRole('slider'), { target: { value: '7' } }); + + expect(onValueChange).toHaveBeenCalledWith(7); + }); +}); + +describe('getDefaultRenderer', () => { + it('resolves every registered type', () => { + for (const type of Object.keys(defaultRenderersMap)) { + expect(getDefaultRenderer(type)).toBe(defaultRenderersMap[type]); + } + }); + + it('returns undefined for an unknown type', () => { + expect(getDefaultRenderer('nope')).toBeUndefined(); + }); +}); diff --git a/packages/react/test/extendedFeatures.test.tsx b/packages/react/test/extendedFeatures.test.tsx new file mode 100644 index 0000000..1373633 --- /dev/null +++ b/packages/react/test/extendedFeatures.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + DynamicInput, + useDynamicForm, + DynamicFormDevTools, + FieldDescription, +} from '../src'; + +describe('React Extended Features', () => { + it('renders radio renderer and updates value', () => { + const options = [ + { label: 'Male', value: 'male' }, + { label: 'Female', value: 'female' }, + ]; + + const handleChange = vi.fn(); + render( + + ); + + const femaleRadio = screen.getByLabelText('Female'); + expect(femaleRadio).not.toBeChecked(); + + fireEvent.click(femaleRadio); + expect(handleChange).toHaveBeenCalledWith('female'); + }); + + it('renders range slider', () => { + const handleChange = vi.fn(); + render( + + ); + + const slider = screen.getByRole('slider'); + expect(slider).toBeInTheDocument(); + }); + + it('useDynamicForm hook initializes and handles submission', async () => { + const fields: FieldDescription[] = [ + { name: 'name', type: 'text', required: true }, + ]; + + function TestComponent() { + const { data, setFieldValue, reset } = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + }); + + return ( +
+ {String(data.name)} + + +
+ ); + } + + render(); + expect(screen.getByTestId('name-val').textContent).toBe('Alice'); + + fireEvent.click(screen.getByText('Change Name')); + expect(screen.getByTestId('name-val').textContent).toBe('Bob'); + + fireEvent.click(screen.getByText('Reset Form')); + expect(screen.getByTestId('name-val').textContent).toBe('Alice'); + }); + + it('renders DevTools button and opens overlay', () => { + render( + + ); + + const devToolsBtn = screen.getByText('🔍 DevTools'); + expect(devToolsBtn).toBeInTheDocument(); + + fireEvent.click(devToolsBtn); + expect(screen.getByText('🛠️ Form DevTools')).toBeInTheDocument(); + }); +}); diff --git a/packages/react/test/useDynamicForm.test.tsx b/packages/react/test/useDynamicForm.test.tsx new file mode 100644 index 0000000..dae6c9e --- /dev/null +++ b/packages/react/test/useDynamicForm.test.tsx @@ -0,0 +1,210 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { FieldDescription } from '../src'; +import { useDynamicForm } from '../src'; + +const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: (v) => (v ? undefined : 'Name is required'), + }, + { name: 'nickname', type: 'text' }, +]; + +describe('useDynamicForm submission state', () => { + it('starts out not submitting and not submitted', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.isSubmitted).toBe(false); + }); + + it('flags isSubmitting while the submit handler is in flight', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + let submission!: Promise; + act(() => { + submission = result.current.handleSubmit(() => pending)(); + }); + + await waitFor(() => expect(result.current.isSubmitting).toBe(true)); + + await act(async () => { + release(); + await submission; + }); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.isSubmitted).toBe(true); + }); + + it('clears isSubmitting when the submit handler throws', async () => { + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + await act(async () => { + await expect( + result.current.handleSubmit(() => { + throw new Error('boom'); + })() + ).rejects.toThrow('boom'); + }); + + expect(result.current.isSubmitting).toBe(false); + }); + + it('marks isSubmitted even when validation fails', async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const { result } = renderHook(() => useDynamicForm({ fields })); + + await act(async () => { + await result.current.handleSubmit(onValid, onInvalid)(); + }); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ name: ['Name is required'] }); + expect(result.current.isSubmitted).toBe(true); + expect(result.current.isSubmitting).toBe(false); + }); + + it('resets submission state', async () => { + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + await act(async () => { + await result.current.handleSubmit(vi.fn())(); + }); + expect(result.current.isSubmitted).toBe(true); + + act(() => result.current.reset()); + + expect(result.current.isSubmitted).toBe(false); + expect(result.current.isSubmitting).toBe(false); + }); +}); + +describe('useDynamicForm behaviour', () => { + it('calls preventDefault on the submitted event', async () => { + const preventDefault = vi.fn(); + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + await act(async () => { + await result.current.handleSubmit(vi.fn())({ + preventDefault, + } as unknown as React.FormEvent); + }); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('tracks dirty state and touched fields', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + expect(result.current.isDirty).toBe(false); + + act(() => result.current.setFieldValue('nickname', 'Al')); + expect(result.current.isDirty).toBe(true); + expect(result.current.data.nickname).toBe('Al'); + + act(() => result.current.setFieldTouched('nickname')); + expect(result.current.touched.nickname).toBe(true); + + act(() => result.current.setFieldTouched('nickname', false)); + expect(result.current.touched.nickname).toBe(false); + }); + + it('validates on blur by default', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + act(() => result.current.handleBlur('name')); + + expect(result.current.errors.name).toEqual(['Name is required']); + expect(result.current.touched.name).toBe(true); + expect(result.current.isValid).toBe(false); + }); + + it('skips blur validation when validateOnBlur is false', () => { + const { result } = renderHook(() => + useDynamicForm({ fields, validateOnBlur: false }) + ); + + act(() => result.current.handleBlur('name')); + + expect(result.current.errors).toEqual({}); + expect(result.current.touched.name).toBe(true); + }); + + it('validates on change when validateOnChange is true', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + validateOnChange: true, + }) + ); + + act(() => result.current.setFieldValue('name', '')); + + expect(result.current.errors.name).toEqual(['Name is required']); + }); + + it('exposes an imperative validate()', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + let valid!: boolean; + act(() => { + valid = result.current.validate(); + }); + + expect(valid).toBe(false); + expect(result.current.errors.name).toEqual(['Name is required']); + }); + + it('applies computed values to the initial data', () => { + const computed: FieldDescription[] = [ + { name: 'first', type: 'text' }, + { + name: 'upper', + type: 'text', + computeValue: (d) => String(d.first ?? '').toUpperCase(), + }, + ]; + const { result } = renderHook(() => + useDynamicForm({ fields: computed, initialValues: { first: 'ada' } }) + ); + + expect(result.current.data.upper).toBe('ADA'); + }); + + it('resets to explicitly supplied values', () => { + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + act(() => result.current.reset({ name: 'Grace' })); + + expect(result.current.data.name).toBe('Grace'); + expect(result.current.isDirty).toBe(false); + expect(result.current.errors).toEqual({}); + }); + + it('supports direct setData updates', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + act(() => result.current.setData({ name: 'Direct' })); + + expect(result.current.data.name).toBe('Direct'); + }); +}); diff --git a/packages/vue/src/components/DynamicFormDevTools.ts b/packages/vue/src/components/DynamicFormDevTools.ts new file mode 100644 index 0000000..396f526 --- /dev/null +++ b/packages/vue/src/components/DynamicFormDevTools.ts @@ -0,0 +1,313 @@ +import { FieldDescription, Properties } from '@dynamic-field-kit/core'; +import { defineComponent, h, PropType, ref } from 'vue'; + +export const DynamicFormDevTools = defineComponent({ + name: 'DynamicFormDevTools', + props: { + data: { + type: Object as PropType, + required: true, + }, + errors: { + type: Object as PropType>, + default: () => ({}), + }, + touched: { + type: Object as PropType>, + default: () => ({}), + }, + isDirty: { + type: Boolean, + default: false, + }, + fields: { + type: Array as PropType, + default: () => [], + }, + position: { + type: String as PropType<'bottom-right' | 'bottom-left'>, + default: 'bottom-right', + }, + }, + setup(props) { + const isOpen = ref(false); + const activeTab = ref<'data' | 'errors' | 'meta' | 'fields'>('data'); + + return () => { + const errorKeys = Object.keys(props.errors || {}); + const errorCount = errorKeys.length; + + const posStyle = + props.position === 'bottom-left' + ? { left: '16px', bottom: '16px' } + : { right: '16px', bottom: '16px' }; + + if (!isOpen.value) { + return h( + 'button', + { + type: 'button', + onClick: () => (isOpen.value = true), + style: { + position: 'fixed', + ...posStyle, + zIndex: 99999, + background: '#1e293b', + color: '#f8fafc', + border: '1px solid #334155', + borderRadius: '20px', + padding: '8px 14px', + fontSize: '12px', + fontWeight: 600, + cursor: 'pointer', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + }, + [ + h('span', '🔍 DevTools'), + errorCount > 0 + ? h( + 'span', + { + style: { + background: '#ef4444', + color: '#fff', + borderRadius: '10px', + padding: '2px 6px', + fontSize: '10px', + }, + }, + String(errorCount) + ) + : null, + ] + ); + } + + return h( + 'div', + { + style: { + position: 'fixed', + ...posStyle, + zIndex: 99999, + width: '360px', + maxHeight: '420px', + background: '#0f172a', + color: '#f8fafc', + border: '1px solid #334155', + borderRadius: '12px', + boxShadow: '0 10px 25px rgba(0,0,0,0.3)', + display: 'flex', + flexDirection: 'column', + fontFamily: 'monospace, sans-serif', + fontSize: '12px', + overflow: 'hidden', + }, + }, + [ + // Header + h( + 'div', + { + style: { + padding: '10px 14px', + background: '#1e293b', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + borderBottom: '1px solid #334155', + }, + }, + [ + h( + 'span', + { style: { fontWeight: 'bold', color: '#38bdf8' } }, + '🛠️ Form DevTools' + ), + h( + 'button', + { + type: 'button', + onClick: () => (isOpen.value = false), + style: { + background: 'transparent', + border: 'none', + color: '#94a3b8', + fontSize: '14px', + cursor: 'pointer', + }, + }, + '✕' + ), + ] + ), + // Tabs + h( + 'div', + { + style: { + display: 'flex', + background: '#1e293b', + borderBottom: '1px solid #334155', + }, + }, + (['data', 'errors', 'meta', 'fields'] as const).map((tab) => + h( + 'button', + { + key: tab, + type: 'button', + onClick: () => (activeTab.value = tab), + style: { + flex: 1, + padding: '6px 0', + background: + activeTab.value === tab ? '#0f172a' : 'transparent', + color: activeTab.value === tab ? '#38bdf8' : '#94a3b8', + border: 'none', + cursor: 'pointer', + textTransform: 'capitalize', + fontSize: '11px', + fontWeight: activeTab.value === tab ? 'bold' : 'normal', + }, + }, + `${tab}${ + tab === 'errors' && errorCount > 0 ? ` (${errorCount})` : '' + }` + ) + ) + ), + // Content + h('div', { style: { padding: '12px', overflowY: 'auto', flex: 1 } }, [ + activeTab.value === 'data' + ? h( + 'pre', + { + style: { + margin: 0, + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + color: '#a7f3d0', + }, + }, + JSON.stringify(props.data, null, 2) + ) + : null, + activeTab.value === 'errors' + ? h( + 'div', + errorCount === 0 + ? [ + h( + 'span', + { style: { color: '#4ade80' } }, + '✓ No validation errors' + ), + ] + : Object.entries(props.errors).map(([field, msgs]) => + h( + 'div', + { key: field, style: { marginBottom: '8px' } }, + [ + h( + 'span', + { + style: { color: '#f87171', fontWeight: 'bold' }, + }, + `${field}:` + ), + h( + 'ul', + { style: { margin: '4px 0 0 16px', padding: 0 } }, + msgs.map((m, i) => + h( + 'li', + { key: i, style: { color: '#fca5a5' } }, + m + ) + ) + ), + ] + ) + ) + ) + : null, + activeTab.value === 'meta' + ? h('div', [ + h('div', { style: { marginBottom: '6px' } }, [ + h('span', { style: { color: '#94a3b8' } }, 'isDirty: '), + h( + 'span', + { + style: { color: props.isDirty ? '#facc15' : '#4ade80' }, + }, + String(props.isDirty) + ), + ]), + h('div', [ + h( + 'span', + { style: { color: '#94a3b8' } }, + 'Touched Fields:' + ), + h( + 'pre', + { style: { margin: '4px 0 0 0', color: '#cbd5e1' } }, + JSON.stringify(props.touched, null, 2) + ), + ]), + ]) + : null, + activeTab.value === 'fields' + ? h( + 'div', + props.fields.length === 0 + ? [ + h( + 'span', + { style: { color: '#94a3b8' } }, + 'No field descriptions passed' + ), + ] + : props.fields.map((f) => + h( + 'div', + { + key: f.name, + style: { + padding: '6px', + marginBottom: '6px', + background: '#1e293b', + borderRadius: '4px', + }, + }, + [ + h( + 'div', + { + style: { color: '#38bdf8', fontWeight: 'bold' }, + }, + f.name + ), + h( + 'div', + { style: { color: '#94a3b8', fontSize: '10px' } }, + `type: ${f.type} | required: ${String( + Boolean(f.required) + )}` + ), + ] + ) + ) + ) + : null, + ]), + ] + ); + }; + }, +}); diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts index 2f8cb82..afe71ba 100644 --- a/packages/vue/src/components/DynamicInput.ts +++ b/packages/vue/src/components/DynamicInput.ts @@ -20,6 +20,14 @@ const DynamicInput = defineComponent({ type: Function as PropType<(value: unknown) => void>, default: undefined, }, + onBlur: { + type: Function as PropType<() => void>, + default: undefined, + }, + touched: { + type: Boolean, + default: undefined, + }, label: { type: String, default: undefined, @@ -70,6 +78,8 @@ const DynamicInput = defineComponent({ ...props.extraProps, value: props.value, 'onUpdate:value': props.onChange, + onBlur: props.onBlur, + touched: props.touched, label: props.label, options: props.options, class: props.className, diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts index ab4fd4e..d4d25eb 100644 --- a/packages/vue/src/components/FieldInput.ts +++ b/packages/vue/src/components/FieldInput.ts @@ -28,6 +28,14 @@ const FieldInput = defineComponent({ type: Function as PropType<(value: unknown, key: string) => void>, required: true, }, + onBlurField: { + type: Function as PropType<(key: string) => void>, + default: undefined, + }, + touched: { + type: Boolean, + default: undefined, + }, }, setup(props) { return () => { @@ -82,7 +90,9 @@ const FieldInput = defineComponent({ ariaInvalid: Boolean(errorList), ariaRequired: Boolean(required), extraProps, + touched: props.touched, onChange: (v: unknown) => props.onValueChangeField(v, name), + onBlur: () => props.onBlurField?.(name), }); }; }, diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index ce27493..eecb661 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -70,6 +70,14 @@ const MultiFieldInput = defineComponent({ type: Function as PropType<(result: ValidationResult) => void>, default: undefined, }, + // Called with a field's name when it loses focus. Touched state is tracked + // internally either way; this is the hook for driving an external form + // store - pass `useDynamicForm`'s `handleBlur` to get its `touched` map and + // `validateOnBlur` behaviour. + onBlurField: { + type: Function as PropType<(fieldName: string) => void>, + default: undefined, + }, }, setup(props) { @@ -77,6 +85,12 @@ const MultiFieldInput = defineComponent({ // per-property dependency tracking lets each FieldInput re-render only // when the specific key it reads actually changes. const data = reactive({}); + const touchedFields = reactive>({}); + + function handleBlurField(key: string) { + touchedFields[key] = true; + props.onBlurField?.(key); + } watch( () => props.properties, @@ -240,7 +254,9 @@ const MultiFieldInput = defineComponent({ fieldDescription: f, renderInfos: data, rootData: props.rootData ?? data, + touched: Boolean(touchedFields[f.name]), onValueChangeField: handleValueChange, + onBlurField: handleBlurField, }) ); diff --git a/packages/vue/src/defaultRenderers.ts b/packages/vue/src/defaultRenderers.ts index 0b1af92..e750184 100644 --- a/packages/vue/src/defaultRenderers.ts +++ b/packages/vue/src/defaultRenderers.ts @@ -245,6 +245,205 @@ export const DefaultSelectRenderer = defineComponent({ }, }); +export const DefaultRadioRenderer = defineComponent({ + name: 'DefaultRadioRenderer', + props: { + value: null, + onValueChange: Function as PropType<(val: unknown) => void>, + 'onUpdate:value': Function as PropType<(val: unknown) => void>, + onBlur: Function as PropType<() => void>, + disabled: Boolean, + readOnly: Boolean, + required: Boolean, + options: { + type: Array as PropType, + default: () => [], + }, + id: String, + class: String, + ariaInvalid: Boolean, + ariaDescribedBy: String, + }, + setup(props) { + return () => { + const emitChange = props['onUpdate:value'] || props.onValueChange; + const radioNodes = (props.options || []).map((opt, i) => { + const optVal = opt.value ?? opt.id ?? opt; + const optLabel = opt.label ?? opt.name ?? String(optVal); + const isChecked = String(props.value) === String(optVal); + const radioId = `${props.id || 'radio'}-${i}`; + return h( + 'label', + { + key: String(optVal) + i, + style: { + marginRight: '12px', + display: 'inline-flex', + alignItems: 'center', + }, + }, + [ + h('input', { + type: 'radio', + id: radioId, + name: props.id, + value: String(optVal), + checked: isChecked, + onChange: () => emitChange?.(optVal), + disabled: props.disabled || props.readOnly, + required: props.required, + 'aria-invalid': props.ariaInvalid, + 'aria-describedby': props.ariaDescribedBy, + }), + h('span', { style: { marginLeft: '4px' } }, String(optLabel)), + ] + ); + }); + + return h( + 'div', + { + class: `dfk-radio-group ${props.class || ''}`, + id: props.id, + onBlur: props.onBlur, + }, + radioNodes + ); + }; + }, +}); + +export const DefaultRangeRenderer = defineComponent({ + name: 'DefaultRangeRenderer', + props: { + value: null, + onValueChange: Function as PropType<(val: unknown) => void>, + 'onUpdate:value': Function as PropType<(val: unknown) => void>, + onBlur: Function as PropType<() => void>, + disabled: Boolean, + readOnly: Boolean, + required: Boolean, + min: [Number, String], + max: [Number, String], + step: [Number, String], + id: String, + class: String, + ariaInvalid: Boolean, + ariaDescribedBy: String, + }, + setup(props) { + return () => { + const emitChange = props['onUpdate:value'] || props.onValueChange; + return h('input', { + type: 'range', + id: props.id, + class: props.class, + value: (props.value as number) ?? props.min ?? 0, + min: props.min, + max: props.max, + step: props.step, + onInput: (e: Event) => + emitChange?.(Number((e.target as HTMLInputElement).value)), + onBlur: props.onBlur, + disabled: props.disabled || props.readOnly, + required: props.required, + 'aria-invalid': props.ariaInvalid, + 'aria-describedby': props.ariaDescribedBy, + }); + }; + }, +}); + +export const DefaultFileRenderer = defineComponent({ + name: 'DefaultFileRenderer', + props: { + value: null, + onValueChange: Function as PropType<(val: unknown) => void>, + 'onUpdate:value': Function as PropType<(val: unknown) => void>, + onBlur: Function as PropType<() => void>, + disabled: Boolean, + readOnly: Boolean, + required: Boolean, + accept: String, + multiple: Boolean, + id: String, + class: String, + ariaInvalid: Boolean, + ariaDescribedBy: String, + }, + setup(props) { + return () => { + const emitChange = props['onUpdate:value'] || props.onValueChange; + return h('input', { + type: 'file', + id: props.id, + class: props.class, + accept: props.accept, + multiple: props.multiple, + onChange: (e: Event) => { + const files = (e.target as HTMLInputElement).files; + if (!files) { + return; + } + emitChange?.(props.multiple ? Array.from(files) : files[0] || null); + }, + onBlur: props.onBlur, + disabled: props.disabled || props.readOnly, + required: props.required, + 'aria-invalid': props.ariaInvalid, + 'aria-describedby': props.ariaDescribedBy, + }); + }; + }, +}); + +export const DefaultDateRenderer = defineComponent({ + name: 'DefaultDateRenderer', + setup(props, { attrs }) { + return () => + h(DefaultTextRenderer as any, { + ...props, + ...attrs, + inputType: 'date', + }); + }, +}); + +export const DefaultTimeRenderer = defineComponent({ + name: 'DefaultTimeRenderer', + setup(props, { attrs }) { + return () => + h(DefaultTextRenderer as any, { + ...props, + ...attrs, + inputType: 'time', + }); + }, +}); + +export const DefaultDateTimeLocalRenderer = defineComponent({ + name: 'DefaultDateTimeLocalRenderer', + setup(props, { attrs }) { + return () => + h(DefaultTextRenderer as any, { + ...props, + ...attrs, + inputType: 'datetime-local', + }); + }, +}); + +export const DefaultSwitchRenderer = defineComponent({ + name: 'DefaultSwitchRenderer', + setup(props, { attrs }) { + return () => + h(DefaultCheckboxRenderer as any, { + ...props, + ...attrs, + }); + }, +}); + export const defaultRenderersMap: Record = { text: DefaultTextRenderer, number: DefaultNumberRenderer, @@ -253,6 +452,13 @@ export const defaultRenderersMap: Record = { textarea: DefaultTextareaRenderer, checkbox: DefaultCheckboxRenderer, select: DefaultSelectRenderer, + radio: DefaultRadioRenderer, + range: DefaultRangeRenderer, + file: DefaultFileRenderer, + date: DefaultDateRenderer, + time: DefaultTimeRenderer, + 'datetime-local': DefaultDateTimeLocalRenderer, + switch: DefaultSwitchRenderer, }; export function getDefaultRenderer(type: string): any { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 2414f28..384f3db 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -12,6 +12,8 @@ export { useFieldRegistry, FieldRegistryKey, } from './fieldRegistryContext'; +export { useDynamicForm } from './useDynamicForm'; +export { DynamicFormDevTools } from './components/DynamicFormDevTools'; // Re-export selected core APIs export { diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts new file mode 100644 index 0000000..75ab1fa --- /dev/null +++ b/packages/vue/src/useDynamicForm.ts @@ -0,0 +1,115 @@ +import { + applyComputedValues, + FieldDescription, + Properties, + validateFields, +} from '@dynamic-field-kit/core'; +import { computed, ref } from 'vue'; + +export interface UseDynamicFormOptions { + fields: FieldDescription[]; + initialValues?: Properties; + validateOnBlur?: boolean; + validateOnChange?: boolean; +} + +export function useDynamicForm({ + fields, + initialValues = {}, + validateOnBlur = true, + validateOnChange = false, +}: UseDynamicFormOptions) { + const data = ref(applyComputedValues(fields, initialValues)); + const errors = ref>({}); + const isDirty = ref(false); + const touched = ref>({}); + const isSubmitting = ref(false); + const isSubmitted = ref(false); + + const isValid = computed(() => Object.keys(errors.value).length === 0); + + function validate() { + const res = validateFields(fields, data.value); + errors.value = res.errors; + return res.valid; + } + + function handleChange(newData: Properties) { + const next = applyComputedValues(fields, newData); + data.value = next; + isDirty.value = true; + + if (validateOnChange) { + const res = validateFields(fields, next); + errors.value = res.errors; + } + } + + function setFieldValue(name: string, value: unknown) { + handleChange({ ...data.value, [name]: value }); + } + + function setFieldTouched(name: string, isTouched = true) { + touched.value = { ...touched.value, [name]: isTouched }; + } + + function handleBlur(fieldName: string) { + setFieldTouched(fieldName, true); + if (validateOnBlur) { + const res = validateFields(fields, data.value); + errors.value = res.errors; + } + } + + function reset(newValues?: Properties) { + const seed = newValues ?? initialValues; + const next = applyComputedValues(fields, seed); + data.value = next; + errors.value = {}; + isDirty.value = false; + touched.value = {}; + isSubmitting.value = false; + isSubmitted.value = false; + } + + function handleSubmit( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) { + return async (e?: Event) => { + if (e && typeof e.preventDefault === 'function') { + e.preventDefault(); + } + isSubmitting.value = true; + try { + const res = validateFields(fields, data.value); + errors.value = res.errors; + isSubmitted.value = true; + if (res.valid) { + await onValid(data.value); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + isSubmitting.value = false; + } + }; + } + + return { + data, + errors, + isValid, + isDirty, + touched, + isSubmitting, + isSubmitted, + setFieldValue, + setFieldTouched, + handleChange, + handleBlur, + reset, + validate, + handleSubmit, + }; +} diff --git a/packages/vue/test/DynamicFormDevTools.test.ts b/packages/vue/test/DynamicFormDevTools.test.ts new file mode 100644 index 0000000..41c7448 --- /dev/null +++ b/packages/vue/test/DynamicFormDevTools.test.ts @@ -0,0 +1,117 @@ +import { mount, VueWrapper } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import type { FieldDescription } from '../src'; +import { DynamicFormDevTools } from '../src'; + +function mountDevTools(props: Record = {}) { + return mount(DynamicFormDevTools, { props: { data: {}, ...props } }); +} + +async function open(wrapper: VueWrapper) { + await wrapper.find('button').trigger('click'); +} + +async function clickTab(wrapper: VueWrapper, label: string) { + const tab = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().startsWith(label)); + await tab!.trigger('click'); +} + +describe('DynamicFormDevTools (Vue)', () => { + it('renders collapsed without an error badge', () => { + const wrapper = mountDevTools(); + + expect(wrapper.text()).toContain('🔍 DevTools'); + expect(wrapper.text()).not.toContain('🛠️ Form DevTools'); + }); + + it('shows the error count on the collapsed badge', () => { + const wrapper = mountDevTools({ + errors: { name: ['required'], email: ['invalid'] }, + }); + + expect(wrapper.text()).toContain('2'); + }); + + it('shows form data on the data tab', async () => { + const wrapper = mountDevTools({ data: { email: 'a@b.com' } }); + await open(wrapper); + + expect(wrapper.text()).toContain('a@b.com'); + }); + + it('lists errors on the errors tab', async () => { + const wrapper = mountDevTools({ + errors: { email: ['Invalid email', 'Too short'] }, + }); + await open(wrapper); + await clickTab(wrapper, 'errors'); + + expect(wrapper.text()).toContain('email:'); + expect(wrapper.text()).toContain('Invalid email'); + expect(wrapper.text()).toContain('Too short'); + }); + + it('reports a clean form on the errors tab', async () => { + const wrapper = mountDevTools(); + await open(wrapper); + await clickTab(wrapper, 'errors'); + + expect(wrapper.text()).toContain('✓ No validation errors'); + }); + + it('shows dirty and touched state on the meta tab', async () => { + const wrapper = mountDevTools({ isDirty: true, touched: { email: true } }); + await open(wrapper); + await clickTab(wrapper, 'meta'); + + expect(wrapper.text()).toContain('isDirty:'); + expect(wrapper.text()).toContain('true'); + expect(wrapper.text()).toContain('"email": true'); + }); + + it('lists field descriptions on the fields tab', async () => { + const fields: FieldDescription[] = [ + { name: 'email', type: 'text', required: true }, + ]; + const wrapper = mountDevTools({ fields }); + await open(wrapper); + await clickTab(wrapper, 'fields'); + + expect(wrapper.text()).toContain('email'); + expect(wrapper.text()).toContain('type: text | required: true'); + }); + + it('explains when no field descriptions were passed', async () => { + const wrapper = mountDevTools(); + await open(wrapper); + await clickTab(wrapper, 'fields'); + + expect(wrapper.text()).toContain('No field descriptions passed'); + }); + + it('shows the error count in the errors tab label', async () => { + const wrapper = mountDevTools({ errors: { name: ['required'] } }); + await open(wrapper); + + expect(wrapper.text()).toContain('errors (1)'); + }); + + it('closes the overlay again', async () => { + const wrapper = mountDevTools(); + await open(wrapper); + + const closeButton = wrapper.findAll('button').find((b) => b.text() === '✕'); + await closeButton!.trigger('click'); + + expect(wrapper.text()).not.toContain('🛠️ Form DevTools'); + expect(wrapper.text()).toContain('🔍 DevTools'); + }); + + it('anchors to the bottom left when asked', () => { + const wrapper = mountDevTools({ position: 'bottom-left' }); + + expect(wrapper.find('button').attributes('style')).toContain('left: 16px'); + }); +}); diff --git a/packages/vue/test/MultiFieldInputBlur.test.ts b/packages/vue/test/MultiFieldInputBlur.test.ts new file mode 100644 index 0000000..aeccba4 --- /dev/null +++ b/packages/vue/test/MultiFieldInputBlur.test.ts @@ -0,0 +1,78 @@ +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { fieldRegistry } from '@dynamic-field-kit/core'; +import { mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, PropType } from 'vue'; +import MultiFieldInput from '../src/components/MultiFieldInput'; +import '../src/layout/defaultLayouts'; + +declare module '@dynamic-field-kit/core' { + interface FieldTypeMap { + text: string; + } +} + +const fields: FieldDescription[] = [ + { name: 'first', type: 'text', label: 'First' }, + { name: 'second', type: 'text', label: 'Second' }, +]; + +const TextRenderer = defineComponent({ + name: 'TextRenderer', + props: { + value: null, + id: String, + onBlur: Function as PropType<() => void>, + 'onUpdate:value': Function as PropType<(v: unknown) => void>, + }, + setup(props) { + return () => + h('input', { + 'data-testid': props.id, + value: (props.value as string) ?? '', + onBlur: props.onBlur, + }); + }, +}); + +describe('MultiFieldInput blur reporting (Vue)', () => { + beforeEach(() => { + fieldRegistry.register('text', TextRenderer); + }); + + it('reports which field was blurred', async () => { + const onBlurField = vi.fn(); + const wrapper = mount(MultiFieldInput, { + props: { fieldDescriptions: fields, onBlurField }, + }); + + await wrapper.find('[data-testid="dfk-field-second"]').trigger('blur'); + + expect(onBlurField).toHaveBeenCalledWith('second'); + }); + + it('reports each field separately', async () => { + const onBlurField = vi.fn(); + const wrapper = mount(MultiFieldInput, { + props: { fieldDescriptions: fields, onBlurField }, + }); + + await wrapper.find('[data-testid="dfk-field-first"]').trigger('blur'); + await wrapper.find('[data-testid="dfk-field-second"]').trigger('blur'); + + expect(onBlurField.mock.calls.map(([name]) => name)).toEqual([ + 'first', + 'second', + ]); + }); + + it('works without a handler', async () => { + const wrapper = mount(MultiFieldInput, { + props: { fieldDescriptions: fields }, + }); + + await expect( + wrapper.find('[data-testid="dfk-field-first"]').trigger('blur') + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/vue/test/defaultRenderersExtra.test.ts b/packages/vue/test/defaultRenderersExtra.test.ts new file mode 100644 index 0000000..7d49024 --- /dev/null +++ b/packages/vue/test/defaultRenderersExtra.test.ts @@ -0,0 +1,195 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { + DefaultDateRenderer, + DefaultDateTimeLocalRenderer, + DefaultFileRenderer, + DefaultRadioRenderer, + DefaultRangeRenderer, + DefaultSelectRenderer, + DefaultSwitchRenderer, + DefaultTimeRenderer, + defaultRenderersMap, + getDefaultRenderer, +} from '../src/defaultRenderers'; + +describe('date/time renderers (Vue)', () => { + it.each([ + ['date', DefaultDateRenderer], + ['time', DefaultTimeRenderer], + ['datetime-local', DefaultDateTimeLocalRenderer], + ])('renders an input of type %s', (type, Renderer) => { + const wrapper = mount(Renderer as never, { props: { value: '' } }); + + expect(wrapper.find('input').attributes('type')).toBe(type); + }); + + it('emits the raw string value on input', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultTimeRenderer as never, { + props: { value: '', onValueChange }, + }); + + await wrapper.find('input').setValue('10:30'); + + expect(onValueChange).toHaveBeenCalledWith('10:30'); + }); +}); + +describe('DefaultSwitchRenderer (Vue)', () => { + it('renders a checkbox reflecting the value', () => { + const wrapper = mount(DefaultSwitchRenderer as never, { + props: { value: true }, + }); + + const input = wrapper.find('input'); + expect(input.attributes('type')).toBe('checkbox'); + expect((input.element as HTMLInputElement).checked).toBe(true); + }); + + it('emits the checked state', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultSwitchRenderer as never, { + props: { value: false, onValueChange }, + }); + + await wrapper.find('input').setValue(true); + + expect(onValueChange).toHaveBeenCalledWith(true); + }); +}); + +describe('DefaultFileRenderer (Vue)', () => { + function changeFiles(wrapper: ReturnType, files: File[]) { + const input = wrapper.find('input').element as HTMLInputElement; + Object.defineProperty(input, 'files', { value: files, writable: false }); + return wrapper.find('input').trigger('change'); + } + + it('emits a single File by default', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultFileRenderer as never, { + props: { onValueChange }, + }); + const file = new File(['x'], 'a.txt'); + + await changeFiles(wrapper, [file]); + + expect(onValueChange).toHaveBeenCalledWith(file); + }); + + it('emits an array when multiple is set', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultFileRenderer as never, { + props: { onValueChange, multiple: true }, + }); + const a = new File(['a'], 'a.txt'); + const b = new File(['b'], 'b.txt'); + + await changeFiles(wrapper, [a, b]); + + expect(onValueChange).toHaveBeenCalledWith([a, b]); + }); + + it('emits null when a single selection is cleared', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultFileRenderer as never, { + props: { onValueChange }, + }); + + await changeFiles(wrapper, []); + + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it('forwards accept and multiple', () => { + const wrapper = mount(DefaultFileRenderer as never, { + props: { accept: '.png', multiple: true }, + }); + + expect(wrapper.find('input').attributes('accept')).toBe('.png'); + expect(wrapper.find('input').attributes('multiple')).toBeDefined(); + }); +}); + +describe('option-shaped fallbacks (Vue)', () => { + it('falls back to id/name when a select option has no value/label', () => { + const wrapper = mount(DefaultSelectRenderer as never, { + props: { value: '1', options: [{ id: '1', name: 'One' }] }, + }); + + const option = wrapper.findAll('option')[1]; + expect(option.text()).toBe('One'); + expect(option.attributes('value')).toBe('1'); + }); + + it('falls back to id/name when a radio option has no value/label', () => { + const wrapper = mount(DefaultRadioRenderer as never, { + props: { value: '1', options: [{ id: '1', name: 'One' }], id: 'rad' }, + }); + + expect(wrapper.text()).toContain('One'); + expect( + (wrapper.find('input[type="radio"]').element as HTMLInputElement).checked + ).toBe(true); + }); + + it('emits the option value when a radio is picked', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultRadioRenderer as never, { + props: { + value: 'a', + options: [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + ], + onValueChange, + }, + }); + + await wrapper.findAll('input[type="radio"]')[1].trigger('change'); + + expect(onValueChange).toHaveBeenCalledWith('b'); + }); + + it('treats a readOnly select as disabled', () => { + const wrapper = mount(DefaultSelectRenderer as never, { + props: { value: '', options: [], readOnly: true }, + }); + + expect(wrapper.find('select').attributes('disabled')).toBeDefined(); + }); +}); + +describe('DefaultRangeRenderer (Vue)', () => { + it('falls back to min when no value is set', () => { + const wrapper = mount(DefaultRangeRenderer as never, { + props: { min: 5, max: 10 }, + }); + + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('5'); + }); + + it('emits a number on input', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultRangeRenderer as never, { + props: { value: 5, min: 0, max: 10, onValueChange }, + }); + + await wrapper.find('input').setValue('7'); + + expect(onValueChange).toHaveBeenCalledWith(7); + }); +}); + +describe('getDefaultRenderer (Vue)', () => { + it('resolves every registered type', () => { + for (const type of Object.keys(defaultRenderersMap)) { + expect(getDefaultRenderer(type)).toBe(defaultRenderersMap[type]); + } + }); + + it('returns undefined for an unknown type', () => { + expect(getDefaultRenderer('nope')).toBeUndefined(); + }); +}); diff --git a/packages/vue/test/extendedFeatures.test.ts b/packages/vue/test/extendedFeatures.test.ts new file mode 100644 index 0000000..10362b1 --- /dev/null +++ b/packages/vue/test/extendedFeatures.test.ts @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { + FieldInput, + useDynamicForm, + DynamicFormDevTools, + FieldDescription, +} from '../src'; + +describe('Vue Extended Features', () => { + it('useDynamicForm composable works properly', () => { + const fields: FieldDescription[] = [ + { name: 'name', type: 'text', required: true }, + ]; + + const { data, setFieldValue, reset } = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + }); + + expect(data.value.name).toBe('Alice'); + + setFieldValue('name', 'Bob'); + expect(data.value.name).toBe('Bob'); + + reset(); + expect(data.value.name).toBe('Alice'); + }); + + it('renders radio renderer in Vue', async () => { + const field: FieldDescription = { + name: 'plan', + type: 'radio', + label: 'Plan', + options: [ + { label: 'Free', value: 'free' }, + { label: 'Pro', value: 'pro' }, + ], + }; + + const wrapper = mount(FieldInput, { + props: { + fieldDescription: field, + renderInfos: { plan: 'free' }, + onValueChangeField: vi.fn(), + }, + }); + + expect(wrapper.find('.dfk-radio-group').exists()).toBe(true); + }); + + it('renders DevTools button in Vue', async () => { + const wrapper = mount(DynamicFormDevTools, { + props: { + data: { name: 'Alice' }, + isDirty: false, + }, + }); + + expect(wrapper.text()).toContain('🔍 DevTools'); + await wrapper.find('button').trigger('click'); + expect(wrapper.text()).toContain('🛠️ Form DevTools'); + }); +}); diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts new file mode 100644 index 0000000..12a323c --- /dev/null +++ b/packages/vue/test/useDynamicForm.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FieldDescription } from '../src'; +import { useDynamicForm } from '../src'; + +const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: (v) => (v ? undefined : 'Name is required'), + }, + { name: 'nickname', type: 'text' }, +]; + +describe('useDynamicForm submission state', () => { + it('starts out not submitting and not submitted', () => { + const form = useDynamicForm({ fields }); + + expect(form.isSubmitting.value).toBe(false); + expect(form.isSubmitted.value).toBe(false); + }); + + it('flags isSubmitting while the submit handler is in flight', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const form = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + }); + + const submission = form.handleSubmit(() => pending)(); + expect(form.isSubmitting.value).toBe(true); + + release(); + await submission; + + expect(form.isSubmitting.value).toBe(false); + expect(form.isSubmitted.value).toBe(true); + }); + + it('clears isSubmitting when the submit handler throws', async () => { + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + await expect( + form.handleSubmit(() => { + throw new Error('boom'); + })() + ).rejects.toThrow('boom'); + + expect(form.isSubmitting.value).toBe(false); + }); + + it('routes validation failures to onInvalid', async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const form = useDynamicForm({ fields }); + + await form.handleSubmit(onValid, onInvalid)(); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ name: ['Name is required'] }); + expect(form.isSubmitted.value).toBe(true); + }); + + it('calls preventDefault on the submitted event', async () => { + const preventDefault = vi.fn(); + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + await form.handleSubmit(vi.fn())({ preventDefault } as unknown as Event); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('resets submission state', async () => { + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + await form.handleSubmit(vi.fn())(); + expect(form.isSubmitted.value).toBe(true); + + form.reset(); + + expect(form.isSubmitted.value).toBe(false); + expect(form.isSubmitting.value).toBe(false); + }); +}); + +describe('useDynamicForm behaviour', () => { + it('tracks dirty state and touched fields', () => { + const form = useDynamicForm({ fields }); + + expect(form.isDirty.value).toBe(false); + + form.setFieldValue('nickname', 'Al'); + expect(form.isDirty.value).toBe(true); + expect(form.data.value.nickname).toBe('Al'); + + form.setFieldTouched('nickname'); + expect(form.touched.value.nickname).toBe(true); + + form.setFieldTouched('nickname', false); + expect(form.touched.value.nickname).toBe(false); + }); + + it('validates on blur by default', () => { + const form = useDynamicForm({ fields }); + + form.handleBlur('name'); + + expect(form.errors.value.name).toEqual(['Name is required']); + expect(form.touched.value.name).toBe(true); + expect(form.isValid.value).toBe(false); + }); + + it('skips blur validation when validateOnBlur is false', () => { + const form = useDynamicForm({ fields, validateOnBlur: false }); + + form.handleBlur('name'); + + expect(form.errors.value).toEqual({}); + expect(form.touched.value.name).toBe(true); + }); + + it('validates on change when validateOnChange is true', () => { + const form = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + validateOnChange: true, + }); + + form.setFieldValue('name', ''); + + expect(form.errors.value.name).toEqual(['Name is required']); + }); + + it('exposes an imperative validate()', () => { + const form = useDynamicForm({ fields }); + + expect(form.validate()).toBe(false); + expect(form.errors.value.name).toEqual(['Name is required']); + }); + + it('applies computed values to the initial data', () => { + const computed: FieldDescription[] = [ + { name: 'first', type: 'text' }, + { + name: 'upper', + type: 'text', + computeValue: (d) => String(d.first ?? '').toUpperCase(), + }, + ]; + + const form = useDynamicForm({ + fields: computed, + initialValues: { first: 'ada' }, + }); + + expect(form.data.value.upper).toBe('ADA'); + }); + + it('resets to explicitly supplied values', () => { + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + form.reset({ name: 'Grace' }); + + expect(form.data.value.name).toBe('Grace'); + expect(form.isDirty.value).toBe(false); + expect(form.errors.value).toEqual({}); + }); + + it('handles a whole-form change', () => { + const form = useDynamicForm({ fields }); + + form.handleChange({ name: 'Bulk', nickname: 'B' }); + + expect(form.data.value).toEqual({ name: 'Bulk', nickname: 'B' }); + expect(form.isDirty.value).toBe(true); + }); +}); diff --git a/scripts/create-changeset.js b/scripts/create-changeset.js new file mode 100644 index 0000000..0a5b2ad --- /dev/null +++ b/scripts/create-changeset.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Writes a changeset file from command-line arguments instead of the + * interactive `npx changeset` prompt, so a release can be triggered from CI + * (or a one-liner locally) by picking a bump level. + * + * node scripts/create-changeset.js --bump minor --packages core,react \ + * --message "Add schema adapters" + * + * Omitting --packages targets every publishable package. Any changesets that + * are already pending are left alone: `changeset version` merges them all and + * the largest bump per package wins. + */ + +const fs = require('fs'); +const path = require('path'); + +const VALID_BUMPS = ['patch', 'minor', 'major']; +const REPO_ROOT = path.resolve(__dirname, '..'); +const PACKAGES_DIR = path.join(REPO_ROOT, 'packages'); +const CHANGESET_DIR = path.join(REPO_ROOT, '.changeset'); + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + continue; + } + const key = arg.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i += 1; + } + } + return args; +} + +/** Every package under packages/ that is actually published to npm. */ +function readPublishablePackages() { + return fs + .readdirSync(PACKAGES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const manifestPath = path.join(PACKAGES_DIR, entry.name, 'package.json'); + if (!fs.existsSync(manifestPath)) { + return undefined; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (!manifest.name || manifest.private === true) { + return undefined; + } + return { dir: entry.name, name: manifest.name }; + }) + .filter(Boolean); +} + +/** Accepts either `core` or `@dynamic-field-kit/core`. */ +function resolveRequested(requested, publishable) { + return requested.map((token) => { + const match = publishable.find( + (pkg) => pkg.name === token || pkg.dir === token + ); + if (!match) { + const known = publishable.map((p) => p.dir).join(', '); + throw new Error(`Unknown package "${token}". Known packages: ${known}`); + } + return match.name; + }); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const bump = String(args.bump || '').trim(); + + if (!VALID_BUMPS.includes(bump)) { + throw new Error( + `--bump must be one of ${VALID_BUMPS.join(', ')} (received "${bump}")` + ); + } + + const publishable = readPublishablePackages(); + if (publishable.length === 0) { + throw new Error(`No publishable packages found under ${PACKAGES_DIR}`); + } + + const requestedRaw = + typeof args.packages === 'string' ? args.packages.trim() : ''; + const names = requestedRaw + ? resolveRequested( + requestedRaw + .split(',') + .map((token) => token.trim()) + .filter(Boolean), + publishable + ) + : publishable.map((pkg) => pkg.name); + + if (names.length === 0) { + throw new Error('--packages resolved to an empty list'); + } + + const message = + (typeof args.message === 'string' && args.message.trim()) || + `Release ${bump} version`; + + const frontMatter = names.map((name) => `'${name}': ${bump}`).join('\n'); + const body = `---\n${frontMatter}\n---\n\n${message}\n`; + + fs.mkdirSync(CHANGESET_DIR, { recursive: true }); + const fileName = `auto-${bump}-${Date.now()}.md`; + const filePath = path.join(CHANGESET_DIR, fileName); + fs.writeFileSync(filePath, body, 'utf8'); + + process.stdout.write( + `Created .changeset/${fileName}\n` + + ` bump: ${bump}\n` + + ` packages: ${names.join(', ')}\n` + + ` message: ${message}\n` + ); +} + +try { + main(); +} catch (error) { + process.stderr.write(`create-changeset: ${error.message}\n`); + process.exit(1); +}