From 59f6f00386311ada4bdca15653c575413e9a949d Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 21 Oct 2026 00:57:08 +0700 Subject: [PATCH] chore(release): sync v1.12.0 to master --- .changeset/README.md | 10 +- .github/dependabot.yml | 33 +- .github/workflows/cache-cleanup.yml | 58 + .github/workflows/ci.yml | 40 +- .github/workflows/deploy-pages.yml | 10 + .github/workflows/quality-gates.yml | 6 +- .github/workflows/release.yml | 67 +- .gitignore | 3 + .prettierignore | 3 + README.md | 38 +- docs/MIGRATING.md | 42 + example/angular-app/.gitignore | 3 +- example/angular-app/README.md | 2 +- example/angular-app/package-lock.json | 14758 ++++++++++++++++ example/pages-index.html | 7 + example/shared/i18n.ts | 4 +- example/shared/schemas.ts | 282 + example/svelte-app/README.md | 14 +- example/svelte-app/src/App.svelte | 2 +- .../src/demos/EnterpriseDemo.svelte | 4 + handbook/.vitepress/config.mts | 8 + handbook/scripts/sync.mjs | 6 + handbook/vi/guide/conditions.md | 65 + handbook/vi/guide/fields.md | 3 + handbook/vi/guide/json-schema.md | 6 +- handbook/vi/guide/validation.md | 2 +- package-lock.json | 3457 ++-- package.json | 17 +- packages/angular/CHANGELOG.md | 13 + packages/angular/README.md | 17 +- packages/angular/package.json | 22 +- .../angular/src/components/DynamicInput.ts | 276 +- packages/angular/src/components/FieldInput.ts | 9 +- .../angular/src/components/MultiFieldInput.ts | 5 +- packages/angular/src/public-api.ts | 1 + packages/angular/test/MultiFieldInput.spec.ts | 22 + packages/angular/test/builtInTypes.spec.ts | 205 + .../test/defaultRendererLabels.spec.ts | 148 + packages/core/CHANGELOG.md | 8 + packages/core/README.md | 108 +- packages/core/package.json | 8 +- packages/core/src/conditions.ts | 166 + packages/core/src/index.ts | 5 + packages/core/src/jsonSchema.ts | 256 +- packages/core/src/jsonSchemaConditions.ts | 241 + packages/core/src/jsonSchemaExport.ts | 84 +- packages/core/src/rendererProps.ts | 17 +- packages/core/src/types.ts | 55 + packages/core/src/validation.ts | 46 +- packages/core/test/conditions.test.ts | 333 + .../core/test/jsonSchemaConditions.test.ts | 537 + packages/core/test/jsonSchemaExport.test.ts | 3 +- packages/core/test/rendererProps.test.ts | 8 + packages/core/test/types.test-d.ts | 34 + packages/react/CHANGELOG.md | 12 + packages/react/README.md | 10 +- packages/react/package.json | 18 +- .../react/src/components/DynamicInput.tsx | 42 +- .../react/src/components/MultiFieldInput.tsx | 5 +- packages/react/src/defaultRenderers.tsx | 12 +- packages/react/src/index.ts | 1 + packages/react/test/MultiFieldInput.test.tsx | 30 + .../react/test/defaultRendererLabels.test.tsx | 111 + packages/svelte/CHANGELOG.md | 13 + packages/svelte/README.md | 45 +- packages/svelte/package.json | 16 +- .../src/components/DynamicFormDevTools.svelte | 189 + .../svelte/src/components/DynamicInput.svelte | 45 +- .../src/components/MultiFieldInput.svelte | 4 +- packages/svelte/src/index.ts | 6 + .../svelte/src/renderers/RadioRenderer.svelte | 4 +- .../svelte/test/DynamicFormDevTools.test.ts | 161 + packages/svelte/test/MultiFieldInput.test.ts | 31 + .../svelte/test/defaultRendererLabels.test.ts | 113 + packages/vue/CHANGELOG.md | 12 + packages/vue/README.md | 10 +- packages/vue/package.json | 14 +- packages/vue/src/components/DynamicInput.ts | 53 +- .../vue/src/components/MultiFieldInput.ts | 6 +- packages/vue/src/defaultRenderers.ts | 6 +- packages/vue/src/index.ts | 1 + packages/vue/test/MultiFieldInput.test.ts | 24 + .../vue/test/defaultRendererLabels.test.ts | 131 + scripts/own-release-packages.js | 64 + scripts/own-release-packages.test.js | 57 + smoke/package.json | 10 +- storybook/.storybook/main.ts | 19 + storybook/.storybook/preview.ts | 15 + storybook/package-lock.json | 5085 ++++++ storybook/package.json | 31 + storybook/src/demos/FieldPlayground.tsx | 46 + storybook/src/demos/FormDemo.tsx | 70 + storybook/src/demos/WizardDemo.tsx | 89 + storybook/src/stories/FieldTypes.stories.tsx | 47 + storybook/src/stories/Forms.stories.tsx | 74 + storybook/src/stories/Wizard.stories.tsx | 18 + storybook/test/a11y.test.tsx | 32 + storybook/test/setup.ts | 9 + storybook/test/stories.test.tsx | 58 + storybook/tsconfig.json | 24 + storybook/vite.shared.ts | 21 + storybook/vitest.config.ts | 13 + 102 files changed, 26471 insertions(+), 2013 deletions(-) create mode 100644 .github/workflows/cache-cleanup.yml create mode 100644 example/angular-app/package-lock.json create mode 100644 example/shared/schemas.ts create mode 100644 handbook/vi/guide/conditions.md create mode 100644 packages/angular/test/builtInTypes.spec.ts create mode 100644 packages/angular/test/defaultRendererLabels.spec.ts create mode 100644 packages/core/src/conditions.ts create mode 100644 packages/core/src/jsonSchemaConditions.ts create mode 100644 packages/core/test/conditions.test.ts create mode 100644 packages/core/test/jsonSchemaConditions.test.ts create mode 100644 packages/react/test/defaultRendererLabels.test.tsx create mode 100644 packages/svelte/src/components/DynamicFormDevTools.svelte create mode 100644 packages/svelte/test/DynamicFormDevTools.test.ts create mode 100644 packages/svelte/test/defaultRendererLabels.test.ts create mode 100644 packages/vue/test/defaultRendererLabels.test.ts create mode 100644 scripts/own-release-packages.js create mode 100644 scripts/own-release-packages.test.js create mode 100644 storybook/.storybook/main.ts create mode 100644 storybook/.storybook/preview.ts create mode 100644 storybook/package-lock.json create mode 100644 storybook/package.json create mode 100644 storybook/src/demos/FieldPlayground.tsx create mode 100644 storybook/src/demos/FormDemo.tsx create mode 100644 storybook/src/demos/WizardDemo.tsx create mode 100644 storybook/src/stories/FieldTypes.stories.tsx create mode 100644 storybook/src/stories/Forms.stories.tsx create mode 100644 storybook/src/stories/Wizard.stories.tsx create mode 100644 storybook/test/a11y.test.tsx create mode 100644 storybook/test/setup.ts create mode 100644 storybook/test/stories.test.tsx create mode 100644 storybook/tsconfig.json create mode 100644 storybook/vite.shared.ts create mode 100644 storybook/vitest.config.ts diff --git a/.changeset/README.md b/.changeset/README.md index 755a964..8015e86 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -22,7 +22,9 @@ not `fixed`: a patch that only touches angular still must not bump and republish three packages whose tarballs did not change. **`onlyUpdatePeerDependentsWhenOutOfRange`** stops a core release from -rewriting each adapter's `@dynamic-field-kit/core` peer range. The adapters use -only core APIs that have existed since 1.3.0, so `^1.3.0` is the honest range — -tightening it on every core release would force consumers to upgrade core for -features their adapter does not call. +rewriting each adapter's `@dynamic-field-kit/core` peer range. The floor is the +oldest core that has every API the adapter imports, and it moves by hand when an +adapter starts calling something new: since 1.12.0 the adapters import +`resolveVisible` and `makeLabelId`, so the range is `^1.12.0`. Tightening it on +every core release would force consumers to upgrade core for features their +adapter does not call. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 88e4516..7fe1df2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,9 +25,11 @@ updates: prefix: chore include: scope ignore: - # @angular/compiler-cli 21 accepts typescript <6.1.0. Move this with Angular. + # The Angular 21 toolchain takes typescript >=5.9 <6.0 (@angular-devkit/build-angular, + # @ngtools/webpack, ng-packagr; compiler-cli alone would take <6.1): 6.0.3 failed npm ci + # with ERESOLVE (#110). Move this with Angular. - dependency-name: typescript - versions: ['>=6.1.0'] + versions: ['>=6.0.0'] # The Angular package is built and tested with Angular 21 (see packages/angular/README.md). # Moving that toolchain to a new major is a change of its own, checked by # scripts/verify-angular-peer-range.js, not a dependency bump. @@ -39,6 +41,33 @@ updates: update-types: ['version-update:semver-major'] - dependency-name: 'ng-packagr' update-types: ['version-update:semver-major'] + # @angular/build 21 has vitest ^4 as a peer, and npm 10 (Node 22, this repo's CI) stops + # with "Cannot read properties of null (reading 'edgesOut')" on vitest 5 (#100). + # @angular/build 22 accepts ^5: move vitest with the Angular toolchain. + - dependency-name: 'vitest' + update-types: ['version-update:semver-major'] + - dependency-name: '@vitest/*' + update-types: ['version-update:semver-major'] + # The React Storybook has its own lockfile. One weekly pull request for all of it: + # Storybook's packages only work at one version together, and the rest is build tooling. + - package-ecosystem: npm + directory: /storybook + schedule: + interval: weekly + groups: + storybook: + patterns: + - '*' + commit-message: + prefix: chore + include: scope + ignore: + # Same hold as the root: vitest moves with the Angular toolchain there. + - dependency-name: 'vitest' + update-types: ['version-update:semver-major'] + # Matches the root's TypeScript hold below 6.0. + - dependency-name: typescript + versions: ['>=6.0.0'] - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 0000000..57a5ca7 --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,58 @@ +name: Cache cleanup + +# Actions caches share a 10 GB limit per repository, and over it GitHub evicts on its +# own, caches still in use among them. Two kinds are dead weight: +# - a pull request's caches, which only that pull request can read: deleted when it +# closes, and by the weekly sweep for any closed before this workflow existed; +# - on a branch, older copies of a cache a newer run saved again under a new key +# (a changed lockfile, a new Gradle state): the sweep deletes a copy left unused for a +# day longer than the newest one of its kind. GitHub would only drop it after 7 days. +on: + pull_request_target: + types: [closed] + schedule: + - cron: '23 3 * * 0' + workflow_dispatch: + +# Nothing is checked out: the workflow reads only the pull request number. +permissions: + actions: write + contents: read + pull-requests: read + +jobs: + cleanup: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Delete caches nothing will read again + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + gh api --paginate "repos/$GH_REPO/actions/caches?per_page=100" --jq '.actions_caches[]' > caches.json + if [ -n "$PR_NUMBER" ]; then + open='[]' + filter='map(select(.ref == "refs/pull/'"$PR_NUMBER"'/merge"))' + else + open=$(gh pr list --state open --limit 500 --json number --jq '[.[] | "refs/pull/\(.number)/merge"]') + filter=' + def t: .last_accessed_at[0:19] + "Z" | fromdateiso8601; + def kind: .key | sub("[-_|][0-9a-f]{12,}.*$"; ""); + map(select(.ref | startswith("refs/pull/")) | select(.ref as $r | $open | index($r) | not)) + + (map(select(.ref | startswith("refs/pull/") | not)) + | group_by([.ref, kind]) + | map((map(t) | max) as $newest | map(select(t < $newest - 86400))) + | add // [])' + fi + jq -rs --argjson open "$open" "$filter | .[] | [.id, .size_in_bytes, .ref, .key] | @tsv" caches.json > delete.tsv + deleted=0; bytes=0 + while IFS=$'\t' read -r id size ref key; do + if gh api -X DELETE "repos/$GH_REPO/actions/caches/$id" > /dev/null; then + echo "deleted $ref $key" + deleted=$((deleted + 1)); bytes=$((bytes + size)) + fi + done < delete.tsv + echo "### Deleted $deleted caches, $((bytes / 1048576)) MB" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee1bc4c..dea335e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,37 @@ jobs: gates: uses: ./.github/workflows/quality-gates.yml + # The React Storybook lives outside the workspaces with its own lockfile, so + # its install never touches the packages' dependency tree. Its stories read + # the packages from source: a story broken by an API change fails here. + storybook: + name: Storybook + runs-on: ubuntu-latest + defaults: + run: + working-directory: storybook + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: storybook/package-lock.json + + - name: Install Storybook dependencies + run: npm ci --no-audit --no-fund + + - name: Typecheck the stories + run: npm run typecheck + + - name: Render every story + run: npm test + + - name: Build Storybook + run: npm run build-storybook + changeset: name: Changeset present runs-on: ubuntu-latest @@ -24,11 +55,16 @@ jobs: # none left; # - any PR into master, which carries changes that were already released # from develop - their changesets are gone by definition, and demanding a - # new one would mean releasing the same work twice. + # new one would mean releasing the same work twice; + # - Dependabot's PRs, which change development dependencies almost always. + # Each one failed here until an empty changeset was pushed to it by hand. + # A dependency update that does need a release gets its changeset with + # the release. if: >- github.event_name == 'pull_request' && github.base_ref == 'develop' && - !startsWith(github.head_ref, 'changeset-release/') + !startsWith(github.head_ref, 'changeset-release/') && + !startsWith(github.head_ref, 'dependabot/') steps: - uses: actions/checkout@v7 with: diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index fe6e89c..eec30bd 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -13,6 +13,7 @@ on: - 'example/**' - 'handbook/**' - 'docs/**' + - 'storybook/**' - 'README.md' - '.github/workflows/deploy-pages.yml' workflow_dispatch: @@ -94,11 +95,20 @@ jobs: npm install --no-audit --no-fund npm run build + # Reads the packages from source (storybook/vite.shared.ts), so it does + # not need the build above; it has its own lockfile and install. + - name: Build Storybook + working-directory: storybook + run: | + npm ci --no-audit --no-fund + npm run build-storybook + - name: Assemble the site run: | mkdir -p _site cp example/pages-index.html _site/index.html cp -r handbook/.vitepress/dist _site/handbook + cp -r storybook/storybook-static _site/storybook cp -r example/react-app/out _site/react cp -r example/vue-app/dist _site/vue cp -r example/svelte-app/dist _site/svelte diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 8a48eba..3b4feb7 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -155,9 +155,9 @@ jobs: - 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. + # Every example has a committed lockfile, which `npm install` honours. angular-app's + # was gitignored until 15/10/2026: CI then resolved it fresh each run, and npm 10 (Node + # 22) crashed resolving it: "Cannot read properties of null (reading 'edgesOut')". - name: Install example dependencies working-directory: example/${{ matrix.app }} run: npm install --no-audit --no-fund diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d9984d..d72cd4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,19 @@ jobs: git tag -a "v$version" -m "Release v$version" -m "$published" git push origin "v$version" fi + + # A package that moved on its own (angular 1.11.1 next to core 1.11.0) has no + # v tag to stand for it: it gets one named after itself. + own=$(node scripts/own-release-packages.js) + while read -r tag pkg pv; do + [ -n "$tag" ] || continue + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Tag $tag already exists - leaving it alone." + else + git tag -a "$tag" -m "Release $tag" + git push origin "$tag" + fi + done <<< "$own" env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -174,26 +187,44 @@ jobs: if gh release view "v$version" >/dev/null 2>&1; then echo "Release v$version already exists - leaving it alone." - exit 0 + else + { + echo 'All five packages ship at this version with npm provenance:' + echo + for pkg in core react vue angular svelte; do + echo "- \`@dynamic-field-kit/$pkg@$(node -p "require('./packages/$pkg/package.json').version")\`" + done + echo + awk -v heading="## $version" \ + '$0 == heading { found = 1; next } /^## / { if (found) exit } found' \ + packages/core/CHANGELOG.md + } > "$RUNNER_TEMP/release-notes.md" + + gh release create "v$version" \ + --title "v$version" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + --verify-tag \ + --latest fi - { - echo 'All five packages ship at this version with npm provenance:' - echo - for pkg in core react vue angular svelte; do - echo "- \`@dynamic-field-kit/$pkg@$(node -p "require('./packages/$pkg/package.json').version")\`" - done - echo - awk -v heading="## $version" \ + # A package that moved on its own gets a Release of its own, from its own + # CHANGELOG. It is not marked latest: v stays the release people land on. + own=$(node scripts/own-release-packages.js) + while read -r tag pkg pv; do + [ -n "$tag" ] || continue + if gh release view "$tag" >/dev/null 2>&1; then + echo "Release $tag already exists - leaving it alone." + continue + fi + awk -v heading="## $pv" \ '$0 == heading { found = 1; next } /^## / { if (found) exit } found' \ - packages/core/CHANGELOG.md - } > "$RUNNER_TEMP/release-notes.md" - - gh release create "v$version" \ - --title "v$version" \ - --notes-file "$RUNNER_TEMP/release-notes.md" \ - --verify-tag \ - --latest + "packages/$pkg/CHANGELOG.md" > "$RUNNER_TEMP/release-notes-$pkg.md" + gh release create "$tag" \ + --title "$tag" \ + --notes-file "$RUNNER_TEMP/release-notes-$pkg.md" \ + --verify-tag \ + --latest=false + done <<< "$own" env: GH_TOKEN: ${{ github.token }} @@ -202,4 +233,6 @@ jobs: run: | echo 'Dry run - nothing was committed, tagged or published.' echo 'Versions that would have shipped are listed above.' + echo 'Packages that would get a tag and a Release of their own (tag, package, version):' + node scripts/own-release-packages.js git --no-pager diff --stat diff --git a/.gitignore b/.gitignore index aa71331..5330843 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ handbook/.vitepress/cache/ # svelte-package's working copy of the Svelte adapter's source .svelte-kit/ + +# Storybook's static build +storybook-static/ diff --git a/.prettierignore b/.prettierignore index e1d5422..4e90786 100644 --- a/.prettierignore +++ b/.prettierignore @@ -38,3 +38,6 @@ Thumbs.db # svelte-package's working copy of the Svelte adapter's source .svelte-kit/ + +# Storybook's static build +storybook-static/ diff --git a/README.md b/README.md index e1260f0..8a2a42f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ [![react downloads](https://img.shields.io/npm/d18m/@dynamic-field-kit/react?label=react%20downloads)](https://www.npmjs.com/package/@dynamic-field-kit/react) [![vue downloads](https://img.shields.io/npm/d18m/@dynamic-field-kit/vue?label=vue%20downloads)](https://www.npmjs.com/package/@dynamic-field-kit/vue) [![angular downloads](https://img.shields.io/npm/d18m/@dynamic-field-kit/angular?label=angular%20downloads)](https://www.npmjs.com/package/@dynamic-field-kit/angular) +[![Official vannt-dev build](https://vannt-dev.github.io/badges/official.svg)](https://vannt-dev.github.io/official.html) **[📘 Handbook](https://vannt-dev.github.io/dynamic-field-kit/handbook/)** — guides for every feature, per framework, with search. Also in @@ -32,7 +33,8 @@ A lightweight, extensible **dynamic form engine** for React, Angular, Vue, and S - Schema-driven dynamic forms - Extensible field types (no enums, no hard-coded unions) - Pluggable field renderers via shared registry -- Runtime conditional fields (`appearCondition`) +- Runtime conditional fields (`appearCondition`), or the same rules as data that survive JSON + (`appearWhen`, `requiredWhen`) - Derived/computed fields (`computeValue`) - Repeatable field groups (`fields`) - "add another item" without leaving the schema - Titled sections (`section`) - a nested object under a heading, validated by path @@ -267,6 +269,8 @@ const fields: FieldDescription[] = [ | mask | Shows the value formatted while it is typed and stores it unformatted: a pattern such as `'(999) 999-9999'` or a `{ format, parse }` pair. See **Input masks** below | | props | Extra, framework-agnostic props forwarded verbatim to the renderer (e.g. `acceptFile`, `maxLength`) - keeps domain-specific inputs out of the adapter layer | | appearCondition | Runtime visibility condition. Called as `(data, rootData)`: `data` is this field's own level (the group item, inside a repeatable group), `rootData` is always the top-level form data | +| appearWhen | The same visibility rule as data, e.g. `{ field: 'country', in: ['US', 'CA'] }`, so the list can be stored as JSON. `appearCondition` wins when both are set. See **Conditions as data** in the core README | +| requiredWhen | Required while a condition (same shape) holds: the renderer gets `required`, and validation reports the `required` message for an empty value | | computeValue | Derives this field's value from the rest of the form data (e.g. a total or full name) whenever any field changes. Called as `(data, rootData)` like `appearCondition`. Evaluated once per change, not to a fixed point, so avoid chaining `computeValue` fields into a cycle, and return a primitive or a stable reference (a fresh object/array each call defeats render-skipping). | **Repeatable Field Groups** @@ -429,7 +433,7 @@ const fields: FieldDescription[] = [ `MultiFieldInput` passes each field's current `error` and effective `disabled`/`readOnly` to its renderer (via `FieldRendererProps`), and emits an `onValidityChange` (`validityChange` in Angular) event with `{ valid, errors }` -on every change. Disabled and hidden (`appearCondition`) fields are skipped - +on every change. Disabled and hidden (`appearCondition` / `appearWhen`) fields are skipped - they never produce errors. For submit-time or async validation of a whole form (including group items) call `validateFields` or `validateFieldsAsync`: @@ -460,7 +464,7 @@ All framework adapters (`react`, `vue`, `angular`, `svelte`) ship with **built-i `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. +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. The label is a `