diff --git a/.changeset/auto-patch-1785949928451.md b/.changeset/auto-patch-1785949928451.md new file mode 100644 index 0000000..fcb54ca --- /dev/null +++ b/.changeset/auto-patch-1785949928451.md @@ -0,0 +1,8 @@ +--- +'@dynamic-field-kit/angular': patch +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Document the v1.4 APIs in each package README: form state, schema adapters, the wizard engine, default renderers, DevTools and blur wiring. README ships in the npm tarball, so this reaches package pages only through a release. diff --git a/.gitignore b/.gitignore index 1ad173d..6cc2f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ yarn-error.log* # scratch/planning docs, not subject to version control .superpowers/ +docs/superpowers/ diff --git a/.prettierignore b/.prettierignore index a35e638..7ea4d6b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,9 @@ coverage/ # Generated type files next-env.d.ts +# Generated by example/angular-app/scripts/embed-demo-sources.js on every build +example/angular-app/src/app/demo-sources.ts + # Logs *.log npm-debug.log* diff --git a/docs/superpowers/plans/2026-07-14-validation-conditions.md b/docs/superpowers/plans/2026-07-14-validation-conditions.md deleted file mode 100644 index 71bfc46..0000000 --- a/docs/superpowers/plans/2026-07-14-validation-conditions.md +++ /dev/null @@ -1,1602 +0,0 @@ -# Validation & Dynamic Conditions Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add opt-in, app-supplied field validation and dynamic disabled/readOnly conditions to dynamic-field-kit, surfaced reactively to renderers, with no rule library or form state in the framework-agnostic core. - -**Architecture:** Core gains three optional `FieldDescription` hooks (`validate`, `disabledCondition`, `readOnlyCondition`), two `FieldRendererProps` fields (`error`, `readOnly`), and a pure `validation.ts` module (`validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly`). Each adapter's `MultiFieldInput`/`FieldInput` computes per-leaf-field `error`/`disabled`/`readOnly` from those helpers (skipping hidden and disabled fields) and forwards them to renderers, and each `MultiFieldInput` emits an `onValidityChange`/`validityChange` event carrying the recursive `validateFields` result. - -**Tech Stack:** TypeScript monorepo (npm workspaces). Core + React (tsup, vitest, @testing-library/react), Vue 3 (tsup, vitest, @vue/test-utils), Angular (ng-packagr, karma/jasmine). - -## Global Constraints - -- Every addition is optional and backward compatible: a schema declaring none of the new hooks, and a form with no validity handler, behaves exactly as before. -- Core ships no validation rule logic and no form state (no touched/submitted tracking). The app writes each `validate`/condition function. -- Validation is synchronous in this cycle (no async `validate`). -- A field is skipped (no error, never invalid) when hidden by `appearCondition` OR disabled (`resolveDisabled` true). `readOnly` fields are still validated. -- Group error paths are keyed `` `${name}[${index}].${childName}` `` (e.g. `contacts[0].email`). -- Callback signatures identical across adapters use the `ValidationResult` shape `{ valid: boolean; errors: Record }`. -- CI gates that must stay green: build all four packages (`npm run build --workspace=@dynamic-field-kit/`), `npm run lint`, `npm run format-check`, per-package tests, and the three verify scripts. Vue tests run with `npx vitest run` (its `npm test` is watch-mode). Build core before adapters typecheck against its `dist`. - -## File Structure - -- `packages/core/src/types.ts` — add hooks to `FieldDescription`; add `error`/`readOnly` to `FieldRendererProps`. (modify) -- `packages/core/src/validation.ts` — new pure module: `ValidationResult`, `validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly`. (create) -- `packages/core/src/index.ts` — re-export `./validation`. (modify) -- `packages/core/test/validation.test.ts` — unit tests. (create) -- `packages/react/src/components/DynamicInput.tsx` — forward `error`/`readOnly`. (modify) -- `packages/react/src/components/FieldInput.tsx` — compute effective `disabled`/`readOnly`/`error`. (modify) -- `packages/react/src/components/MultiFieldInput.tsx` — `onValidityChange` prop + emit. (modify) -- `packages/react/src/index.ts` — re-export validation helpers. (modify) -- `packages/react/test/validation.test.tsx` — adapter tests. (create) -- `packages/vue/src/components/DynamicInput.ts` — forward `error`/`readOnly`. (modify) -- `packages/vue/src/components/FieldInput.ts` — `rootData` prop + compute. (modify) -- `packages/vue/src/components/MultiFieldInput.ts` — pass `rootData` to leaf; `onValidityChange` prop + emit. (modify) -- `packages/vue/src/index.ts` — re-export validation helpers. (modify) -- `packages/vue/test/validation.test.ts` — adapter tests. (create) -- `packages/angular/src/components/BaseInput.ts` — add `error`/`readOnly` inputs. (modify) -- `packages/angular/src/components/DynamicInput.ts` — add `error`/`readOnly` to `KNOWN_PROPS` + fallback props. (modify) -- `packages/angular/src/components/FieldInput.ts` — accept/forward `error`/`disabled`/`readOnly`. (modify) -- `packages/angular/src/components/MultiFieldInput.ts` — `getError`/`getDisabled`/`getReadOnly` + `validityChange` output. (modify) -- `packages/angular/src/public-api.ts` — re-export validation helpers. (modify) -- `packages/angular/test/validation.spec.ts` — adapter tests. (create) -- `README.md` + `packages/*/README.md` — "Validation & conditions" docs. (modify) - ---- - -### Task 1: Core schema + renderer contract additions - -**Files:** - -- Modify: `packages/core/src/types.ts` -- Test: `packages/core/test/types.test.ts` (append) - -**Interfaces:** - -- Produces: `FieldDescription.validate?: (value: unknown, data: Properties, rootData?: Properties) => string | string[] | undefined`; `FieldDescription.disabledCondition?: (data: Properties, rootData?: Properties) => boolean`; `FieldDescription.readOnlyCondition?: (data: Properties, rootData?: Properties) => boolean`; `FieldRendererProps.error?: string | string[]`; `FieldRendererProps.readOnly?: boolean`. - -- [ ] **Step 1: Write the failing test** - -Append to `packages/core/test/types.test.ts`: - -```ts -describe('validation & condition hooks', () => { - test('FieldDescription accepts validate, disabledCondition, readOnlyCondition', () => { - const field: FieldDescription = { - name: 'email', - type: 'text', - validate: (value, data, rootData) => - typeof value === 'string' && value.includes('@') - ? undefined - : 'Invalid email', - disabledCondition: (data) => data.locked === true, - readOnlyCondition: (data, rootData) => (rootData ?? data).frozen === true, - }; - - expect(field.validate?.('a', {}, {})).toBe('Invalid email'); - expect(field.validate?.('a@b', {}, {})).toBeUndefined(); - expect(field.disabledCondition?.({ locked: true })).toBe(true); - expect(field.readOnlyCondition?.({}, { frozen: true })).toBe(true); - }); - - test('FieldRendererProps accepts error and readOnly', () => { - const props: FieldRendererProps = { - value: '', - error: ['Required'], - readOnly: true, - }; - expect(props.error).toEqual(['Required']); - expect(props.readOnly).toBe(true); - }); -}); -``` - -Note: `types.test.ts` already imports `FieldDescription`, `FieldRendererProps`, and augments `FieldTypeMap` with `text`. If `FieldRendererProps` is not yet imported there, add it to the existing import from `../src`. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run test/types.test.ts` -Expected: FAIL — TypeScript errors that `validate`/`disabledCondition`/`readOnlyCondition`/`error`/`readOnly` do not exist on the types. - -- [ ] **Step 3: Add the fields to the interfaces** - -In `packages/core/src/types.ts`, in `FieldRendererProps`, after the `disabled?: boolean;` line add: - -```ts - readOnly?: boolean; - error?: string | string[]; -``` - -In `FieldDescription`, immediately after the `disabled?: boolean;` line add: - -```ts - /** - * Returns one or more validation error messages for `value`, or a falsy - * value when it is valid. App-supplied, like appearCondition/computeValue - - * core ships no rule logic. `rootData` is the top-level form (equal to - * `data` outside a group). - */ - validate?: ( - value: unknown, - data: Properties, - rootData?: Properties - ) => string | string[] | undefined; -``` - -In `FieldDescription`, immediately after the `computeValue?: ...` block add: - -```ts - /** Dynamic disabled state, OR-ed with the static `disabled` flag. */ - disabledCondition?: (data: Properties, rootData?: Properties) => boolean; - /** Dynamic read-only state. */ - readOnlyCondition?: (data: Properties, rootData?: Properties) => boolean; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run test/types.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/types.ts packages/core/test/types.test.ts -git commit -m "feat(core): add validate/disabledCondition/readOnlyCondition and renderer error/readOnly" -``` - ---- - -### Task 2: Core `validateField`, `resolveDisabled`, `resolveReadOnly` - -**Files:** - -- Create: `packages/core/src/validation.ts` -- Test: `packages/core/test/validation.test.ts` - -**Interfaces:** - -- Consumes: `FieldDescription`, `Properties` from `./types`. -- Produces: `validateField(field, value, data, rootData?): string[]`; `resolveDisabled(field, data, rootData?): boolean`; `resolveReadOnly(field, data, rootData?): boolean`. - -- [ ] **Step 1: Write the failing test** - -Create `packages/core/test/validation.test.ts`: - -```ts -import { describe, expect, test } from 'vitest'; -import { - resolveDisabled, - resolveReadOnly, - validateField, -} from '../src/validation'; -import type { FieldDescription } from '../src'; - -declare module '../src' { - interface FieldTypeMap { - text: string; - } -} - -describe('validateField', () => { - const base: FieldDescription = { name: 'email', type: 'text' }; - - test('returns [] when there is no validate hook', () => { - expect(validateField(base, 'x', {})).toEqual([]); - }); - - test('wraps a single string into a one-element array', () => { - const field: FieldDescription = { - ...base, - validate: () => 'Required', - }; - expect(validateField(field, '', {})).toEqual(['Required']); - }); - - test('passes arrays through and returns [] for falsy results', () => { - const many: FieldDescription = { ...base, validate: () => ['a', 'b'] }; - const ok: FieldDescription = { ...base, validate: () => undefined }; - expect(validateField(many, '', {})).toEqual(['a', 'b']); - expect(validateField(ok, '', {})).toEqual([]); - }); - - test('receives value, data and rootData', () => { - const field: FieldDescription = { - name: 'city', - type: 'text', - validate: (value, data, rootData) => - `${value}:${data.country}:${rootData?.locale}`, - }; - expect( - validateField(field, 'x', { country: 'vn' }, { locale: 'en' }) - ).toEqual(['x:vn:en']); - }); -}); - -describe('resolveDisabled / resolveReadOnly', () => { - test('resolveDisabled OR-s the static flag and the condition', () => { - expect(resolveDisabled({ name: 'a', type: 'text' }, {})).toBe(false); - expect( - resolveDisabled({ name: 'a', type: 'text', disabled: true }, {}) - ).toBe(true); - expect( - resolveDisabled( - { name: 'a', type: 'text', disabledCondition: (d) => d.lock === true }, - { lock: true } - ) - ).toBe(true); - }); - - test('resolveReadOnly reflects the condition', () => { - expect(resolveReadOnly({ name: 'a', type: 'text' }, {})).toBe(false); - expect( - resolveReadOnly( - { - name: 'a', - type: 'text', - readOnlyCondition: (d) => d.frozen === true, - }, - { frozen: true } - ) - ).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run test/validation.test.ts` -Expected: FAIL — cannot resolve `../src/validation`. - -- [ ] **Step 3: Write the implementation** - -Create `packages/core/src/validation.ts`: - -```ts -import type { FieldDescription, Properties } from './types'; - -export interface ValidationResult { - valid: boolean; - errors: Record; -} - -/** Effective disabled state: the static flag OR the dynamic condition. */ -export function resolveDisabled( - field: FieldDescription, - data: Properties, - rootData?: Properties -): boolean { - return ( - field.disabled === true || - field.disabledCondition?.(data, rootData) === true - ); -} - -/** Effective read-only state from the dynamic condition. */ -export function resolveReadOnly( - field: FieldDescription, - data: Properties, - rootData?: Properties -): boolean { - return field.readOnlyCondition?.(data, rootData) === true; -} - -/** Run one field's validate hook; always returns an array (empty when valid). */ -export function validateField( - field: FieldDescription, - value: unknown, - data: Properties, - rootData?: Properties -): string[] { - if (!field.validate) { - return []; - } - const result = field.validate(value, data, rootData); - if (!result) { - return []; - } - return Array.isArray(result) ? result : [result]; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run test/validation.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/validation.ts packages/core/test/validation.test.ts -git commit -m "feat(core): add validateField, resolveDisabled, resolveReadOnly" -``` - ---- - -### Task 3: Core `validateFields` (recursive) - -**Files:** - -- Modify: `packages/core/src/validation.ts` -- Test: `packages/core/test/validation.test.ts` (append) - -**Interfaces:** - -- Consumes: `validateField`, `resolveDisabled`, `isFieldGroup`. -- Produces: `validateFields(fields, data, rootData?): ValidationResult`; exported `interface ValidationResult { valid: boolean; errors: Record }`. - -- [ ] **Step 1: Write the failing test** - -Append to `packages/core/test/validation.test.ts`: - -```ts -import { validateFields } from '../src/validation'; - -describe('validateFields', () => { - const required = (msg: string) => (v: unknown) => v ? undefined : msg; - - test('collects leaf errors and reports overall validity', () => { - const fields: FieldDescription[] = [ - { name: 'name', type: 'text', validate: required('Name required') }, - { name: 'email', type: 'text' }, - ]; - const result = validateFields(fields, { name: '', email: 'x' }); - expect(result.valid).toBe(false); - expect(result.errors).toEqual({ name: ['Name required'] }); - }); - - test('is valid when everything passes', () => { - const fields: FieldDescription[] = [ - { name: 'name', type: 'text', validate: required('r') }, - ]; - expect(validateFields(fields, { name: 'Ada' })).toEqual({ - valid: true, - errors: {}, - }); - }); - - test('skips fields hidden by appearCondition', () => { - const fields: FieldDescription[] = [ - { - name: 'company', - type: 'text', - appearCondition: (d) => d.type === 'business', - validate: required('Company required'), - }, - ]; - expect(validateFields(fields, { type: 'personal' }).valid).toBe(true); - }); - - test('skips disabled fields but still validates readOnly ones', () => { - const fields: FieldDescription[] = [ - { name: 'a', type: 'text', disabled: true, validate: required('A') }, - { - name: 'b', - type: 'text', - disabledCondition: () => true, - validate: required('B'), - }, - { - name: 'c', - type: 'text', - readOnlyCondition: () => true, - validate: required('C'), - }, - ]; - const result = validateFields(fields, { a: '', b: '', c: '' }); - expect(result.errors).toEqual({ c: ['C'] }); - }); - - test('descends into groups with indexed path keys and threads rootData', () => { - const fields: FieldDescription[] = [ - { - name: 'contacts', - type: 'text', - fields: [ - { - name: 'email', - type: 'text', - validate: (v, _d, rootData) => - rootData?.strict && !v ? 'Email required' : undefined, - }, - ], - }, - ]; - const data = { strict: true, contacts: [{ email: 'a@b' }, { email: '' }] }; - const result = validateFields(fields, data); - expect(result.valid).toBe(false); - expect(result.errors).toEqual({ 'contacts[1].email': ['Email required'] }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/core && npx vitest run test/validation.test.ts` -Expected: FAIL — `validateFields` is not exported. - -- [ ] **Step 3: Write the implementation** - -At the top of `packages/core/src/validation.ts`, add the group helper import above the existing `import type` line: - -```ts -import { isFieldGroup } from './fieldGroup'; -``` - -Then append to the same file: - -```ts -/** - * Recursively validate `fields` against `data`, descending into repeatable - * groups. Skips fields hidden by `appearCondition` or disabled (see - * resolveDisabled); readOnly fields are still validated. Group error keys use - * `${name}[${index}].${childName}`. `rootData` defaults to `data`. - */ -export function validateFields( - fields: FieldDescription[], - data: Properties, - rootData: Properties = data -): ValidationResult { - const errors: Record = {}; - - for (const field of fields) { - if (field.appearCondition && !field.appearCondition(data, rootData)) { - continue; - } - if (resolveDisabled(field, data, rootData)) { - continue; - } - - if (isFieldGroup(field)) { - const items = Array.isArray(data[field.name]) - ? (data[field.name] as Properties[]) - : []; - items.forEach((item, index) => { - const sub = validateFields(field.fields, item, rootData); - for (const [key, messages] of Object.entries(sub.errors)) { - errors[`${field.name}[${index}].${key}`] = messages; - } - }); - continue; - } - - const fieldErrors = validateField(field, data[field.name], data, rootData); - if (fieldErrors.length > 0) { - errors[field.name] = fieldErrors; - } - } - - return { valid: Object.keys(errors).length === 0, errors }; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd packages/core && npx vitest run test/validation.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/validation.ts packages/core/test/validation.test.ts -git commit -m "feat(core): add recursive validateFields" -``` - ---- - -### Task 4: Export validation from core and rebuild - -**Files:** - -- Modify: `packages/core/src/index.ts` - -**Interfaces:** - -- Produces: `@dynamic-field-kit/core` now exports `validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly`, and type `ValidationResult`. - -- [ ] **Step 1: Add the export** - -In `packages/core/src/index.ts`, after the `export * from './layout';` line add: - -```ts -export * from './validation'; -``` - -- [ ] **Step 2: Build core and run its full test suite** - -Run: `cd packages/core && npm run build && npx vitest run` -Expected: build succeeds (DTS included); all tests PASS. - -- [ ] **Step 3: Typecheck core** - -Run: `cd packages/core && npx tsc -p tsconfig.json --noEmit` -Expected: exit 0, no output. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core/src/index.ts -git commit -m "feat(core): export validation module" -``` - ---- - -### Task 5: React adapter wiring - -**Files:** - -- Modify: `packages/react/src/components/DynamicInput.tsx` -- Modify: `packages/react/src/components/FieldInput.tsx` -- Modify: `packages/react/src/components/MultiFieldInput.tsx` -- Modify: `packages/react/src/index.ts` -- Test: `packages/react/test/validation.test.tsx` - -**Interfaces:** - -- Consumes: `validateField`, `resolveDisabled`, `resolveReadOnly`, `validateFields`, `ValidationResult` from `@dynamic-field-kit/core`. -- Produces: `MultiFieldInput` prop `onValidityChange?: (result: ValidationResult) => void`; renderers receive `error`/`readOnly`. - -- [ ] **Step 1: Write the failing test** - -Create `packages/react/test/validation.test.tsx`: - -```tsx -import type { FieldDescription } from '@dynamic-field-kit/core'; -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { afterEach, 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; - } -} - -afterEach(() => { - (fieldRegistry as any).registry = {}; -}); - -function registerTextRenderer() { - fieldRegistry.register('text', (({ - value, - onValueChange, - error, - disabled, - readOnly, - }: any) => ( -
- onValueChange?.(e.target.value)} - /> - {error ? ( - {[].concat(error).join(',')} - ) : null} -
- )) as any); -} - -describe('React validation wiring', () => { - it('surfaces validate() errors to the renderer', () => { - registerTextRenderer(); - const fields: FieldDescription[] = [ - { - name: 'email', - type: 'text', - validate: (v) => (String(v).includes('@') ? undefined : 'Invalid'), - }, - ]; - render( - - ); - expect(screen.getByTestId('error')).toHaveTextContent('Invalid'); - }); - - it('does not surface an error for a disabled field', () => { - registerTextRenderer(); - const fields: FieldDescription[] = [ - { - name: 'email', - type: 'text', - disabled: true, - validate: () => 'Invalid', - }, - ]; - render( - - ); - expect(screen.queryByTestId('error')).toBeNull(); - expect(screen.getByTestId('input')).toBeDisabled(); - }); - - it('applies disabledCondition dynamically and emits onValidityChange', () => { - registerTextRenderer(); - const onValidity = vi.fn(); - const fields: FieldDescription[] = [ - { name: 'type', type: 'text' }, - { - name: 'company', - type: 'text', - disabledCondition: (d) => d.type !== 'business', - validate: (v) => (v ? undefined : 'Required'), - }, - ]; - render( - - ); - // company is disabled -> not invalid - expect(onValidity).toHaveBeenLastCalledWith({ valid: true, errors: {} }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/react && npx vitest run test/validation.test.tsx` -Expected: FAIL — `error` never reaches the renderer; `onValidityChange` prop is ignored. - -- [ ] **Step 3: Forward `error`/`readOnly` in `DynamicInput.tsx`** - -In `packages/react/src/components/DynamicInput.tsx`, add to the `Props` interface (after `disabled?: boolean;`): - -```ts - readOnly?: boolean; - error?: string | string[]; -``` - -Add `readOnly` and `error` to the destructured params (after `disabled,`): - -```ts - readOnly, - error, -``` - -And add them to the `React.createElement(Renderer, { ... })` props object (after `disabled,`): - -```ts - readOnly, - error, -``` - -- [ ] **Step 4: Compute effective props in `FieldInput.tsx`** - -In `packages/react/src/components/FieldInput.tsx`, update the core import to include the helpers: - -```ts -import { - resolveDisabled, - resolveReadOnly, - validateField, - FieldDescription, - Properties, -} from '@dynamic-field-kit/core'; -``` - -In the leaf-field branch (the `return ()` at the end), compute the values just before the `return`: - -```ts -const effectiveDisabled = resolveDisabled( - fieldDescription, - renderInfos, - rootData -); -const readOnly = resolveReadOnly(fieldDescription, renderInfos, rootData); -const errors = effectiveDisabled - ? [] - : validateField(fieldDescription, renderInfos[name], renderInfos, rootData); -const error = errors.length > 0 ? errors : undefined; -``` - -Then change the `` element to pass them (replace the existing `disabled={disabled}` and add two props): - -```tsx - -``` - -Note: the destructured `disabled` from `fieldDescription` is now unused; remove `disabled,` from the `const { ... } = fieldDescription;` destructure to satisfy lint. - -- [ ] **Step 5: Add `onValidityChange` to `MultiFieldInput.tsx`** - -In `packages/react/src/components/MultiFieldInput.tsx`, add to the core import: - -```ts -import { - applyComputedValues, - validateFields, - FieldDescription, - Properties, - type ValidationResult, -} from '@dynamic-field-kit/core'; -``` - -Add to `Props`: - -```ts - onValidityChange?: (result: ValidationResult) => void; -``` - -Destructure it in the component params (after `rootData,`): `onValidityChange,`. - -After the existing `rootDataRef` block, add a ref and an emit effect: - -```ts -const onValidityChangeRef = useRef(onValidityChange); -onValidityChangeRef.current = onValidityChange; - -useEffect(() => { - onValidityChangeRef.current?.( - validateFields(fieldDescriptions, data, rootData) - ); -}, [data, fieldDescriptions, rootData]); -``` - -- [ ] **Step 6: Re-export helpers from `index.ts`** - -In `packages/react/src/index.ts`, add after the existing core re-export block: - -```ts -export { - validateField, - validateFields, - resolveDisabled, - resolveReadOnly, - type ValidationResult, -} from '@dynamic-field-kit/core'; -``` - -- [ ] **Step 7: Run the new test and the full React suite** - -Run: `cd packages/react && npx vitest run` -Expected: PASS (new `validation.test.tsx` plus all existing tests). - -- [ ] **Step 8: Commit** - -```bash -git add packages/react/src packages/react/test/validation.test.tsx -git commit -m "feat(react): surface validation errors and dynamic disabled/readOnly; onValidityChange" -``` - ---- - -### Task 6: Vue adapter wiring - -**Files:** - -- Modify: `packages/vue/src/components/DynamicInput.ts` -- Modify: `packages/vue/src/components/FieldInput.ts` -- Modify: `packages/vue/src/components/MultiFieldInput.ts` -- Modify: `packages/vue/src/index.ts` -- Test: `packages/vue/test/validation.test.ts` - -**Interfaces:** - -- Consumes: `validateField`, `resolveDisabled`, `resolveReadOnly`, `validateFields`, `ValidationResult` from `@dynamic-field-kit/core`. -- Produces: `MultiFieldInput` prop `onValidityChange?: (result: ValidationResult) => void`; leaf `FieldInput` now takes a `rootData` prop; renderers receive `error`/`readOnly`. - -- [ ] **Step 1: Write the failing test** - -Create `packages/vue/test/validation.test.ts`: - -```ts -import type { FieldDescription } from '@dynamic-field-kit/core'; -import { mount } from '@vue/test-utils'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { h } from 'vue'; -import MultiFieldInput from '../src/components/MultiFieldInput'; -import { fieldRegistry } from '../src'; -import '../src/layout/defaultLayouts'; - -declare module '@dynamic-field-kit/core' { - interface FieldTypeMap { - text: string; - } -} - -afterEach(() => { - (fieldRegistry as any).registry = {}; -}); - -function registerTextRenderer() { - (fieldRegistry as any).registry['text'] = { - props: ['value', 'error', 'disabled', 'readOnly'], - emits: ['update:value'], - setup(props: any) { - return () => - h('div', [ - h('input', { - 'data-testid': 'input', - disabled: !!props.disabled, - value: props.value ?? '', - }), - props.error - ? h( - 'span', - { class: 'error' }, - ([] as string[]).concat(props.error).join(',') - ) - : null, - ]); - }, - }; -} - -describe('Vue validation wiring', () => { - it('surfaces validate() errors to the renderer', () => { - registerTextRenderer(); - const fields: FieldDescription[] = [ - { - name: 'email', - type: 'text', - validate: (v) => (String(v).includes('@') ? undefined : 'Invalid'), - }, - ]; - const wrapper = mount(MultiFieldInput, { - props: { fieldDescriptions: fields, properties: { email: 'x' } }, - }); - expect(wrapper.find('.error').text()).toBe('Invalid'); - }); - - it('does not surface an error for a disabled field', () => { - registerTextRenderer(); - const fields: FieldDescription[] = [ - { - name: 'email', - type: 'text', - disabled: true, - validate: () => 'Invalid', - }, - ]; - const wrapper = mount(MultiFieldInput, { - props: { fieldDescriptions: fields, properties: { email: '' } }, - }); - expect(wrapper.find('.error').exists()).toBe(false); - }); - - it('emits onValidityChange with the recursive result', () => { - registerTextRenderer(); - const onValidityChange = vi.fn(); - const fields: FieldDescription[] = [ - { - name: 'name', - type: 'text', - validate: (v) => (v ? undefined : 'Required'), - }, - ]; - mount(MultiFieldInput, { - props: { - fieldDescriptions: fields, - properties: { name: '' }, - onValidityChange, - }, - }); - expect(onValidityChange).toHaveBeenLastCalledWith({ - valid: false, - errors: { name: ['Required'] }, - }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/vue && npx vitest run test/validation.test.ts` -Expected: FAIL — no `error` prop reaches the renderer; `onValidityChange` not emitted. - -- [ ] **Step 3: Forward `error`/`readOnly` in `DynamicInput.ts`** - -In `packages/vue/src/components/DynamicInput.ts`, add two props to the `props` object (after the `disabled` prop): - -```ts - readOnly: { - type: Boolean, - default: undefined, - }, - error: { - type: [String, Array] as PropType, - default: undefined, - }, -``` - -In the `h(Renderer.value, { ... })` call, add (after `disabled: props.disabled,`): - -```ts - readOnly: props.readOnly, - error: props.error, -``` - -- [ ] **Step 4: Add `rootData` + computed props in `FieldInput.ts`** - -Rewrite `packages/vue/src/components/FieldInput.ts` to: - -```ts -import { - resolveDisabled, - resolveReadOnly, - validateField, - FieldDescription, - Properties, -} from '@dynamic-field-kit/core'; -import { defineComponent, h, PropType } from 'vue'; -import DynamicInput from './DynamicInput'; - -const FieldInput = defineComponent({ - name: 'FieldInput', - props: { - fieldDescription: { - type: Object as PropType, - required: true, - }, - renderInfos: { - type: Object as PropType, - required: true, - }, - rootData: { - type: Object as PropType, - default: undefined, - }, - onValueChangeField: { - type: Function as PropType<(value: unknown, key: string) => void>, - required: true, - }, - }, - setup(props) { - return () => { - const { - name, - type, - label, - options, - className, - description, - props: extraProps, - } = props.fieldDescription; - - const disabled = resolveDisabled( - props.fieldDescription, - props.renderInfos, - props.rootData - ); - const readOnly = resolveReadOnly( - props.fieldDescription, - props.renderInfos, - props.rootData - ); - const errors = disabled - ? [] - : validateField( - props.fieldDescription, - props.renderInfos[name], - props.renderInfos, - props.rootData - ); - - return h(DynamicInput, { - type, - label, - value: props.renderInfos[name], - options, - className, - description, - disabled, - readOnly, - error: errors.length > 0 ? errors : undefined, - extraProps, - onChange: (v: unknown) => props.onValueChangeField(v, name), - }); - }; - }, -}); - -export default FieldInput; -``` - -- [ ] **Step 5: Pass `rootData` to leaf `FieldInput` and emit validity in `MultiFieldInput.ts`** - -In `packages/vue/src/components/MultiFieldInput.ts`, add to the core import (alongside the existing named imports): - -```ts - validateFields, -``` - -and add a type import near the top-level imports: - -```ts -import type { ValidationResult } from '@dynamic-field-kit/core'; -``` - -Add an `onValidityChange` prop to the `props` object (after `rootData`): - -```ts - onValidityChange: { - type: Function as PropType<(result: ValidationResult) => void>, - default: undefined, - }, -``` - -In the leaf `h(FieldInput, { ... })` call inside the render function, add `rootData`: - -```ts - rootData: props.rootData ?? data, -``` - -Inside `setup`, after the existing `watch(() => props.properties, ...)` block, add a validity watch: - -```ts -watch( - () => [props.fieldDescriptions, { ...data }] as const, - () => { - props.onValidityChange?.( - validateFields(props.fieldDescriptions, { ...data }, props.rootData) - ); - }, - { immediate: true, deep: true } -); -``` - -- [ ] **Step 6: Re-export helpers from `index.ts`** - -In `packages/vue/src/index.ts`, add to the core re-export block (with the other `export { ... } from '@dynamic-field-kit/core'`): - -```ts -export { - validateField, - validateFields, - resolveDisabled, - resolveReadOnly, - type ValidationResult, -} from '@dynamic-field-kit/core'; -``` - -- [ ] **Step 7: Run the new test and the full Vue suite** - -Run: `cd packages/vue && npx vitest run` -Expected: PASS (new `validation.test.ts` plus all existing tests). - -- [ ] **Step 8: Commit** - -```bash -git add packages/vue/src packages/vue/test/validation.test.ts -git commit -m "feat(vue): surface validation errors and dynamic disabled/readOnly; onValidityChange" -``` - ---- - -### Task 7: Angular adapter wiring - -**Files:** - -- Modify: `packages/angular/src/components/BaseInput.ts` -- Modify: `packages/angular/src/components/DynamicInput.ts` -- Modify: `packages/angular/src/components/FieldInput.ts` -- Modify: `packages/angular/src/components/MultiFieldInput.ts` -- Modify: `packages/angular/src/public-api.ts` -- Test: `packages/angular/test/validation.spec.ts` - -**Interfaces:** - -- Consumes: `validateField`, `resolveDisabled`, `resolveReadOnly`, `validateFields`, `ValidationResult` from `@dynamic-field-kit/core`. -- Produces: `MultiFieldInput` output `@Output() validityChange = new EventEmitter()`; `FieldInput` inputs `error`/`disabled`/`readOnly`; renderers receive `error`/`readOnly`. - -- [ ] **Step 1: Write the failing test** - -Create `packages/angular/test/validation.spec.ts`: - -```ts -import { validateFields, resolveDisabled } from '@dynamic-field-kit/core'; -import type { FieldDescription } from '@dynamic-field-kit/core'; - -describe('Angular validation helpers (via core)', () => { - it('validateFields skips disabled fields', () => { - const fields: FieldDescription[] = [ - { name: 'a', type: 'text' as any, disabled: true, validate: () => 'A' }, - { - name: 'b', - type: 'text' as any, - validate: (v: unknown) => (v ? undefined : 'B'), - }, - ]; - const result = validateFields(fields, { a: '', b: '' }); - expect(result.valid).toBe(false); - expect(result.errors).toEqual({ b: ['B'] }); - }); - - it('resolveDisabled OR-s static flag and condition', () => { - const field: FieldDescription = { - name: 'x', - type: 'text' as any, - disabledCondition: (d: any) => d.lock === true, - }; - expect(resolveDisabled(field, { lock: false })).toBe(false); - expect(resolveDisabled(field, { lock: true })).toBe(true); - }); -}); -``` - -(Angular's existing specs are logic-level; this mirrors that style and exercises the wiring's core dependency. The component wiring below is covered by build-time template type-checking plus the shared core tests.) - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd packages/angular && npm test` -Expected: FAIL — core validation helpers not yet exported to the Angular test build (until core is rebuilt) OR assertion mismatch. If core `dist` is current from Task 4, this test passes on its own; still run it to confirm the harness picks up the new spec, then proceed to wire the components. - -- [ ] **Step 3: Add `error`/`readOnly` inputs to `BaseInput.ts`** - -In `packages/angular/src/components/BaseInput.ts`, add to `FieldInputProps` (after `description?: string;`): - -```ts - readOnly?: boolean; - error?: string | string[]; -``` - -Add to `BaseInputComponent` (after `@Input() description?: string;`): - -```ts - @Input() readOnly?: boolean; - @Input() error?: string | string[]; -``` - -- [ ] **Step 4: Forward the new props in `DynamicInput.ts`** - -In `packages/angular/src/components/DynamicInput.ts`, add `'readOnly'` and `'error'` to the `KNOWN_PROPS` array (after `'description',`): - -```ts - 'readOnly', - 'error', -``` - -In `getFallbackProps()`, add two entries (after `description: this.description ?? '',`): - -```ts - readOnly: this.readOnly ?? false, - error: this.error, -``` - -- [ ] **Step 5: Accept and forward `error`/`disabled`/`readOnly` in `FieldInput.ts`** - -Rewrite `packages/angular/src/components/FieldInput.ts` to add the inputs and bind them: - -```ts -import { NgIf } from '@angular/common'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - Input, - OnChanges, - Output, - SimpleChanges, -} from '@angular/core'; -import { FieldDescription } from '@dynamic-field-kit/core'; -import { DynamicInput } from './DynamicInput'; - -@Component({ - selector: 'dfk-field-input', - standalone: true, - imports: [NgIf, DynamicInput], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` - - `, -}) -export class FieldInput implements OnChanges { - @Input() fieldDescription?: FieldDescription; - @Input() value?: unknown; - @Input() disabled?: boolean; - @Input() readOnly?: boolean; - @Input() error?: string | string[]; - @Output() onValueChangeField = new EventEmitter<{ - value: unknown; - key: string; - }>(); - - shouldRender = false; - - constructor(private cdr: ChangeDetectorRef) {} - - ngOnChanges(_changes: SimpleChanges): void { - this.shouldRender = !!this.fieldDescription; - this.cdr.markForCheck(); - } -} -``` - -- [ ] **Step 6: Compute per-field values and emit validity in `MultiFieldInput.ts`** - -In `packages/angular/src/components/MultiFieldInput.ts`, add to the core import: - -```ts - resolveDisabled, - resolveReadOnly, - validateField, - validateFields, -``` - -and add a type import: - -```ts -import type { ValidationResult } from '@dynamic-field-kit/core'; -``` - -In the leaf `` element in the template, add three bindings (after `[value]="data[field.name]"`): - -```html -[disabled]="getDisabled(field)" [readOnly]="getReadOnly(field)" -[error]="getError(field)" -``` - -Add the output near the other `@Output()`s: - -```ts - @Output() validityChange = new EventEmitter(); -``` - -Add the helper methods (near `getItems`): - -```ts - getDisabled(field: FieldDescription): boolean { - return resolveDisabled(field, this.data, this.rootData); - } - - getReadOnly(field: FieldDescription): boolean { - return resolveReadOnly(field, this.data, this.rootData); - } - - getError(field: FieldDescription): string[] | undefined { - if (this.getDisabled(field)) { - return undefined; - } - const errors = validateField( - field, - this.data[field.name], - this.data, - this.rootData - ); - return errors.length > 0 ? errors : undefined; - } -``` - -In `commitData(nextData)`, after `this.updateVisibleFields();`, add: - -```ts -this.validityChange.emit( - validateFields(this.fieldDescriptions, this.data, this.rootData) -); -``` - -In `init()`, after `this.updateVisibleFields();`, add: - -```ts -this.validityChange.emit( - validateFields(this.fieldDescriptions, this.data, this.rootData) -); -``` - -- [ ] **Step 7: Re-export helpers from `public-api.ts`** - -In `packages/angular/src/public-api.ts`, after the `export { fieldRegistry, FieldRegistry } from '@dynamic-field-kit/core';` line add: - -```ts -export { - validateField, - validateFields, - resolveDisabled, - resolveReadOnly, -} from '@dynamic-field-kit/core'; -export type { ValidationResult } from '@dynamic-field-kit/core'; -``` - -- [ ] **Step 8: Rebuild core, build Angular, run Angular tests** - -Run: `cd packages/core && npm run build && cd ../angular && npm run build && npm test` -Expected: core build OK; Angular package build OK (template type-check passes); all Angular tests PASS. - -- [ ] **Step 9: Commit** - -```bash -git add packages/angular/src packages/angular/test/validation.spec.ts -git commit -m "feat(angular): surface validation errors and dynamic disabled/readOnly; validityChange" -``` - ---- - -### Task 8: Documentation - -**Files:** - -- Modify: `README.md` -- Modify: `packages/core/README.md` -- Modify: `packages/react/README.md` -- Modify: `packages/vue/README.md` -- Modify: `packages/angular/README.md` - -**Interfaces:** - -- Consumes: nothing (docs only). - -- [ ] **Step 1: Root README — add a "Validation & conditions" section** - -In `README.md`, after the "Repeatable Field Groups" block (before the "Field Registry (Render Layer)" block), add: - -````markdown -**Validation & conditions** - -Fields can declare an app-supplied `validate` hook plus dynamic -`disabledCondition` / `readOnlyCondition`. The library ships no rule logic and -holds no form state: it runs your functions and surfaces the result. _When_ to -display an error is the renderer's/app's decision. - -| Property | Description | -| ----------------- | --------------------------------------------------------------------------------- | -| validate | `(value, data, rootData?) => string \| string[] \| undefined`. Falsy means valid. | -| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. | -| readOnlyCondition | `(data, rootData?) => boolean`. | - -`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 - they never produce errors. For submit-time validation of a -whole form (including group items) call the exported pure function: - -```ts -import { validateFields } from '@dynamic-field-kit/core'; - -const { valid, errors } = validateFields(fields, data); -// errors: { "email": ["Invalid"], "contacts[1].email": ["Required"] } -``` -```` - -```` - -- [ ] **Step 2: Core README — document the hooks and helpers** - -In `packages/core/README.md`, in the "What this package provides" list, add after the `applyComputedValues` bullet: - -```markdown -- `validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly` and the `ValidationResult` type for opt-in, app-supplied validation and dynamic disabled/readOnly conditions -```` - -Add a new section before "## Repeatable field groups": - -````markdown -## Validation & conditions - -`validate`, `disabledCondition`, and `readOnlyCondition` are app-supplied hooks -on `FieldDescription` (the library ships no rule logic and no form state). - -```ts -const fields: FieldDescription[] = [ - { - name: 'email', - type: 'text', - validate: (value) => - String(value).includes('@') ? undefined : 'Invalid email', - readOnlyCondition: (data, rootData) => (rootData ?? data).frozen === true, - }, -]; -``` - -`validateFields(fields, data, rootData?)` returns `{ valid, errors }`, recursing -into repeatable groups (keys like `contacts[0].email`) and skipping fields that -are hidden by `appearCondition` or disabled. Adapters call `validateField` / -`resolveDisabled` / `resolveReadOnly` per field to surface `error`, -`disabled`, and `readOnly` to renderers reactively; display timing is the -renderer's/app's concern. -```` - -- [ ] **Step 3: React README — add validation usage + exports** - -In `packages/react/README.md`, in the "Exports" list add: - -```markdown -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` -``` - -Add a section after "## Derived fields with `computeValue`": - -````markdown -## Validation & conditions - -Declare a `validate` hook and dynamic `disabledCondition`/`readOnlyCondition`; -your renderer receives `error`, `disabled`, and `readOnly`. `MultiFieldInput` -emits `onValidityChange`: - -```tsx - setCanSubmit(valid)} -/> -``` - -Read the props inside a renderer: - -```tsx -fieldRegistry.register('text', ({ value, onValueChange, error, disabled }) => ( - -)); -``` -```` - -- [ ] **Step 4: Vue README — add validation usage + exports** - -In `packages/vue/README.md`, in the "Exports" list add: - -```markdown -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` -``` - -Add a section after "## Derived fields with `computeValue`": - -````markdown -## Validation & conditions - -Declare a `validate` hook and dynamic `disabledCondition`/`readOnlyCondition`; -your renderer receives `error`, `disabled`, and `readOnly`, and `MultiFieldInput` -emits `onValidityChange`: - -```vue - -``` - -A renderer reads the props (`error`, `disabled`, `readOnly`) it declares, the -same way it reads `value`/`label`. -```` - -- [ ] **Step 5: Angular README — add validation usage + exports** - -In `packages/angular/README.md`, in the "What it exports" list add: - -```markdown -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` -``` - -Add a section after "## Derived fields with `computeValue`": - -````markdown -## Validation & conditions - -Declare a `validate` hook and dynamic `disabledCondition`/`readOnlyCondition`; -your renderer component receives `error`, `disabled`, and `readOnly` inputs, and -`dfk-multi-field-input` emits `(validityChange)`: - -```html - -``` - -For submit-time whole-form validation, call `validateFields(fields, data)`. -```` - -- [ ] **Step 6: Format-check the docs** - -Run: `npx prettier --write "README.md" "packages/*/README.md" && npm run format-check` -Expected: `format-check` reports "All matched files use Prettier code style!". - -- [ ] **Step 7: Commit** - -```bash -git add README.md packages/core/README.md packages/react/README.md packages/vue/README.md packages/angular/README.md -git commit -m "docs: document validation & dynamic conditions" -``` - ---- - -### Task 9: Full verification - -**Files:** none (verification only). - -- [ ] **Step 1: Build all four packages** - -Run: - -```bash -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 -``` - -Expected: all four build successfully (DTS / Ivy included). - -- [ ] **Step 2: Lint and format-check** - -Run: `npm run lint && npm run format-check` -Expected: lint exits 0; format-check reports all files styled. - -- [ ] **Step 3: Run every package's tests** - -Run: - -```bash -(cd packages/core && npx vitest run) -(cd packages/react && npx vitest run) -(cd packages/vue && npx vitest run) -(cd packages/angular && npm test) -``` - -Expected: all suites PASS. - -- [ ] **Step 4: Run the verify scripts** - -Run: - -```bash -node scripts/verify-framework-deps.js -node scripts/check-cross-framework-imports.js -node scripts/integration-cross-registry.js -``` - -Expected: each prints its OK/passed message and exits 0. - -- [ ] **Step 5: Final commit (if any formatting/lint fixes were needed)** - -```bash -git add -A -git commit -m "chore: validation & conditions verification fixes" || echo "nothing to commit" -``` - ---- - -## Notes for the implementer - -- The `error` prop type is `string | string[]`. Renderers normalise with `[].concat(error)` when displaying. -- Adapters compute per-field `error`/`disabled`/`readOnly` for **leaf** fields only; group fields recurse through their nested `MultiFieldInput`, so nested inline errors work at any depth automatically. -- `onValidityChange`/`validityChange` fires the recursive `validateFields` result for the component's `fieldDescriptions`; on the top-level `MultiFieldInput` that is the whole form. -- Do not add touched/submitted state, a rule library, or async validation — those are explicitly out of scope for this cycle. diff --git a/docs/superpowers/plans/2026-07-16-angular-test-overhaul.md b/docs/superpowers/plans/2026-07-16-angular-test-overhaul.md deleted file mode 100644 index 0ab2924..0000000 --- a/docs/superpowers/plans/2026-07-16-angular-test-overhaul.md +++ /dev/null @@ -1,1410 +0,0 @@ -# Angular Test Overhaul Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the Angular package's fake test suite with real mounted-component tests running on vitest + jsdom, and remove Chrome from CI. - -**Architecture:** Delete karma/jasmine/karma-typescript, which cannot bundle any import. Compile Angular for vitest with `@analogjs/vite-plugin-angular`, initialise TestBed from a `setupFiles` entry that actually loads, and cover `packages/angular/src` with tests that mount components through TestBed. - -**Tech Stack:** vitest 1.6, jsdom 24, `@analogjs/vite-plugin-angular` 1.16, `@analogjs/vitest-angular` 1.16, `@angular-devkit/build-angular` 19, `@vitest/coverage-v8`, Angular 19, zone.js 0.15. - -Spec: `docs/superpowers/specs/2026-07-16-angular-test-overhaul-design.md` - -## Global Constraints - -- Work on branch `feat/angular-test-overhaul`, based on `develop`. -- Never append a `Co-Authored-By: Claude ...` trailer or any Claude attribution to commits. End the commit message at the last content line. -- Build `@dynamic-field-kit/core` before running Angular tests — the adapter resolves `@dynamic-field-kit/core` from its built `dist`, via the workspace symlink in `node_modules/@dynamic-field-kit/core`. -- Do not change Angular's public API or the renderer contract. The only `src/` change permitted is the `isComponentType` fix in Task 3. -- Angular peer range is `>=13 <22`. Do not use Angular APIs newer than v13 in `src/`. (`reflectComponentType` is v14+, so it is NOT used; Task 3 uses the `ɵcmp` static instead.) -- Test env is jsdom. No browser, no karma, no jasmine. -- Vitest version must be `^1.6.0` and jsdom `^24.0.0`, matching `packages/react` and `packages/vue`. -- Tests use `new FieldRegistry()` provided through `FIELD_REGISTRY`. Never use the private-state hack `(fieldRegistry as any).registry = {}`. -- Prettier governs all files: run `npm run format-check` before every commit. -- Angular test count will drop well below 36. That is expected and is not a regression. - -## File Structure - -**Created:** - -- `packages/angular/vitest.config.ts` — vitest + analog plugin config. -- `packages/angular/test/setup.ts` — zone.js + TestBed environment init. Loaded via `setupFiles`. -- `packages/angular/test/helpers/renderers.ts` — shared test renderer components and a registry factory. Every spec imports from here; no spec defines its own renderer. -- `packages/angular/test/DynamicInput.spec.ts` — Task 3, 4. -- `packages/angular/test/MultiFieldInput.spec.ts` — Task 5. -- `packages/angular/test/FieldInput.spec.ts` — Task 6. -- `packages/angular/test/layout.spec.ts` — Task 7 (layoutRegistry, defaultLayouts). -- `packages/angular/test/publicApi.spec.ts` — Task 7 (public-api, module, FIELD_REGISTRY token). - -**Modified:** - -- `packages/angular/package.json` — scripts and devDependencies. -- `packages/angular/src/components/DynamicInput.ts:138-142` — the `isComponentType` fix (Task 3). -- `.github/workflows/ci.yml` — drop Chrome, align test-cmd (Task 8). - -**Deleted:** - -- `packages/angular/karma.conf.js`, `packages/angular/tsconfig.spec.json`, `packages/angular/test.ts` -- `packages/angular/test/angular.spec.ts`, `test/DynamicInput.spec.ts`, `test/FieldInput.spec.ts`, `test/integration.spec.ts`, `test/layouts.spec.ts` - ---- - -### Task 1: Stand up the vitest runner and prove TestBed mounts - -**Files:** - -- Create: `packages/angular/vitest.config.ts` -- Create: `packages/angular/test/setup.ts` -- Create: `packages/angular/test/smoke.spec.ts` -- Modify: `packages/angular/package.json` -- Delete: `packages/angular/karma.conf.js`, `packages/angular/tsconfig.spec.json`, `packages/angular/test.ts`, `packages/angular/test/angular.spec.ts`, `packages/angular/test/DynamicInput.spec.ts`, `packages/angular/test/FieldInput.spec.ts`, `packages/angular/test/integration.spec.ts`, `packages/angular/test/layouts.spec.ts` - -**Interfaces:** - -- Produces: a working `npm test --workspace=@dynamic-field-kit/angular` running vitest+jsdom with TestBed initialised. Every later task depends on this. - -**THIS TASK IS A GATE.** If Step 6 cannot be made to pass, stop and report to the user with the exact error before starting Task 2. Do not write further tests against a runner that does not work. - -- [ ] **Step 1: Remove the karma toolchain** - -```bash -cd packages/angular -rm karma.conf.js tsconfig.spec.json test.ts -rm test/angular.spec.ts test/DynamicInput.spec.ts test/FieldInput.spec.ts test/integration.spec.ts test/layouts.spec.ts -``` - -- [ ] **Step 2: Swap dependencies** - -Run from the repo root (workspace-aware): - -```bash -npm uninstall --workspace=@dynamic-field-kit/angular karma karma-chrome-launcher karma-coverage karma-jasmine karma-typescript jasmine @types/jasmine -npm install --save-dev --workspace=@dynamic-field-kit/angular vitest@^1.6.0 jsdom@^24.0.0 @vitest/coverage-v8@^1.6.0 @analogjs/vite-plugin-angular@^1.16.1 @analogjs/vitest-angular@^1.16.1 @angular-devkit/build-angular@^19.0.0 -``` - -Expected: install completes. `@angular-devkit/build-angular` is large; a few minutes is normal. - -- [ ] **Step 3: Update the package scripts** - -In `packages/angular/package.json`, replace the `test` and `test:coverage` scripts. Delete `test:coverage` entirely; CI will pass `--coverage` through, exactly as it does for react and vue. - -```json - "test": "vitest run", -``` - -The `clean` script keeps `dist-spec` in its rimraf list; drop it since karma-typescript no longer emits there: - -```json - "clean": "rimraf dist coverage", -``` - -- [ ] **Step 4: Create the vitest config** - -Create `packages/angular/vitest.config.ts`: - -```ts -/// -import angular from '@analogjs/vite-plugin-angular'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [angular()], - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['test/setup.ts'], - include: ['test/**/*.spec.ts'], - coverage: { - provider: 'v8', - reportsDirectory: 'coverage', - reporter: ['lcov', 'text-summary'], - include: ['src/**/*.ts'], - }, - }, -}); -``` - -- [ ] **Step 5: Create the setup file that actually gets loaded** - -Create `packages/angular/test/setup.ts`. The `setup-zone` import must come first: it loads `zone.js` and `zone.js/testing` in the order Angular requires. This is the fix for the original bug where `test.ts` was never in karma's `files` list, so TestBed was never initialised. - -```ts -import '@analogjs/vitest-angular/setup-zone'; - -import { getTestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting, -} from '@angular/platform-browser-dynamic/testing'; - -getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting() -); -``` - -- [ ] **Step 6: Write the smoke test — the gate** - -Create `packages/angular/test/smoke.spec.ts`. This proves three things the old suite never could: an import works, a component compiles, and TestBed mounts it into jsdom. - -```ts -import { Component } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { fieldRegistry } from '@dynamic-field-kit/core'; -import { describe, expect, it } from 'vitest'; - -@Component({ - selector: 'dfk-smoke', - standalone: true, - template: `{{ label }}`, -}) -class SmokeComponent { - label = 'mounted'; -} - -describe('vitest + Angular infrastructure', () => { - it('imports @dynamic-field-kit/core', () => { - expect(typeof fieldRegistry.register).toBe('function'); - }); - - it('mounts a component through TestBed', () => { - const fixture = TestBed.createComponent(SmokeComponent); - fixture.detectChanges(); - - const el: HTMLElement = fixture.nativeElement.querySelector('.smoke'); - expect(el.textContent).toBe('mounted'); - }); -}); -``` - -- [ ] **Step 7: Build core, then run the smoke test** - -```bash -npm run build --workspace=@dynamic-field-kit/core -npm run test --workspace=@dynamic-field-kit/angular -``` - -Expected: 2 passed. - -If it fails on zone.js (`Zone is not defined`, or `NG0908: In this configuration Angular requires Zone.js`), the fallback is to replace the first line of `test/setup.ts` with explicit imports in this exact order and re-run: - -```ts -import 'zone.js'; -import 'zone.js/testing'; -``` - -If it still fails, **stop and report to the user.** Include the full error. Do not proceed to Task 2. - -- [ ] **Step 8: Commit** - -```bash -git add packages/angular/vitest.config.ts packages/angular/test/setup.ts packages/angular/test/smoke.spec.ts packages/angular/package.json package-lock.json -git add -u packages/angular -git commit -m "test(angular): replace karma with vitest + jsdom - -karma-typescript could not bundle any import: a spec importing only -@angular/core failed with 'exports is not defined', which is why every -existing spec asserted tautologies instead of importing the package. -karma.conf.js also never loaded test.ts, so TestBed was never initialised. - -Runs on vitest 1.6 + jsdom 24, matching react and vue, with Angular -compiled by @analogjs/vite-plugin-angular. Deletes the five tautological -specs; real tests follow." -``` - ---- - -### Task 2: Shared test renderers - -**Files:** - -- Create: `packages/angular/test/helpers/renderers.ts` - -**Interfaces:** - -- Consumes: the working runner from Task 1. -- Produces: - - - `TextRendererComponent` — standalone Angular component, selector `dfk-test-text`. Inputs: `value?: unknown`, `label?: string`, `placeholder?: string`, `required?: boolean`, `disabled?: boolean`, `readOnly?: boolean`, `error?: string | string[]`, `options?: unknown[]`, `className?: string`, `description?: string`, `hint?: string`. Output: `valueChange: EventEmitter`. Renders ``; `` when `error` is set; `` when `hint` is set. - - `LegacyOutputRendererComponent` — standalone, selector `dfk-test-legacy`. Output is named `onValueChange: EventEmitter` (not `valueChange`). Renders ``, -}) -export class LegacyOutputRendererComponent { - @Input() value?: unknown; - // Deliberately the legacy output name, to cover DynamicInput.bindOutputs. - @Output() onValueChange = new EventEmitter(); -} - -export function fallbackRenderer(props: Record): string { - return `${String(props['label'] ?? '')}:${String( - props['value'] ?? '' - )}`; -} - -export function makeRegistry(): FieldRegistry { - return new FieldRegistry(); -} -``` - -The `*ngIf` in `TextRendererComponent` needs `NgIf`. Add it to the imports array: - -```ts -import { NgIf } from '@angular/common'; -``` - -and set `imports: [NgIf]` on `TextRendererComponent`. - -- [ ] **Step 2: Verify it compiles** - -The helper has no spec of its own; Task 3 is the first to import it. Confirm nothing broke: - -Run: `npm run test --workspace=@dynamic-field-kit/angular` -Expected: 2 passed (the smoke tests). - -- [ ] **Step 3: Commit** - -```bash -git add packages/angular/test/helpers/renderers.ts -git commit -m "test(angular): add shared test renderer components" -``` - ---- - -### Task 3: Confirm and fix the `isComponentType` bug - -**Files:** - -- Create: `packages/angular/test/DynamicInput.spec.ts` -- Modify: `packages/angular/src/components/DynamicInput.ts:138-142` - -**Interfaces:** - -- Consumes: `TextRendererComponent`, `makeRegistry` from Task 2. -- Produces: a `DynamicInput` that renders registered Angular component classes. Tasks 4-6 depend on this working. - -**Context:** `src/components/DynamicInput.ts:138-142` is: - -```ts -private isComponentType(renderer: unknown): boolean { - return ( - typeof renderer === 'object' && renderer !== null && 'cmp' in renderer - ); -} -``` - -The README registers renderers as classes (`fieldRegistry.register('text', TextFieldComponent as any)`). A class is `typeof 'function'`, so this returns `false`, `render()` falls through to `renderFallback`, calls the class without `new`, throws `TypeError`, and the `catch` renders the red "Failed to render field" div. Step 2 proves it; Step 4 fixes it. - -- [ ] **Step 1: Write the failing test** - -Create `packages/angular/test/DynamicInput.spec.ts`: - -```ts -import { TestBed } from '@angular/core/testing'; -import { DynamicInput } from '../src/components/DynamicInput'; -import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { makeRegistry, TextRendererComponent } from './helpers/renderers'; - -describe('DynamicInput', () => { - let registry: ReturnType; - - beforeEach(() => { - registry = makeRegistry(); - TestBed.configureTestingModule({ - imports: [DynamicInput], - providers: [{ provide: FIELD_REGISTRY, useValue: registry }], - }); - }); - - it('renders a registered Angular component class', () => { - registry.register('text', TextRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.componentRef.setInput('value', 'hello'); - fixture.detectChanges(); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - expect(input).not.toBeNull(); - expect(input.value).toBe('hello'); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- DynamicInput` - -Expected: FAIL. `input.txt` is null because the component rendered the error div instead. This is the bug confirmed at runtime. Record the actual message — the rendered DOM should contain "Failed to render field: text". - -- [ ] **Step 3: Report the confirmation** - -The design spec (`## Suspected bug: isComponentType`) says Phase 1's first mounted test decides the hypothesis. It is now decided. State the evidence plainly in the commit message at Step 6 — no need to pause the plan; the user pre-approved fixing it in this cycle. - -- [ ] **Step 4: Fix the predicate** - -In `packages/angular/src/components/DynamicInput.ts`, replace `isComponentType`: - -```ts - // Angular component classes carry a static ɵcmp. Checked instead of - // reflectComponentType() because the peer range starts at Angular 13 and - // reflectComponentType is v14+. Plain function renderers have no ɵcmp and - // fall through to renderFallback. - private isComponentType(renderer: unknown): boolean { - return typeof renderer === 'function' && 'ɵcmp' in renderer; - } -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- DynamicInput` -Expected: PASS. - -Then run the whole suite to confirm nothing regressed: - -Run: `npm run test --workspace=@dynamic-field-kit/angular` -Expected: 3 passed. - -- [ ] **Step 6: Commit** - -```bash -git add packages/angular/test/DynamicInput.spec.ts packages/angular/src/components/DynamicInput.ts -git commit -m "fix(angular): render registered component classes - -isComponentType required typeof renderer === 'object' with a 'cmp' key, -but the documented way to register an Angular renderer is a class, which -is typeof 'function' and carries a static named 'ɵcmp'. Every component -class therefore fell through to renderFallback, was called without new, -threw TypeError, and rendered the red 'Failed to render field' div — the -adapter's primary use case never worked. - -No test could catch it before: karma could not bundle imports, so no spec -ever mounted a component. The new mounted test fails without this fix." -``` - ---- - -### Task 4: Cover the rest of `DynamicInput` - -**Files:** - -- Modify: `packages/angular/test/DynamicInput.spec.ts` - -**Interfaces:** - -- Consumes: `TextRendererComponent`, `LegacyOutputRendererComponent`, `fallbackRenderer`, `makeRegistry` from Task 2. - -- [ ] **Step 1: Add the behaviour tests** - -Append inside the existing `describe('DynamicInput', ...)` block in `packages/angular/test/DynamicInput.spec.ts`. Add these imports to the existing import block: - -```ts -import { - LegacyOutputRendererComponent, - fallbackRenderer, -} from './helpers/renderers'; -``` - -```ts -it('forwards KNOWN_PROPS to the rendered instance', () => { - registry.register('text', TextRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.componentRef.setInput('placeholder', 'Your name'); - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - expect(input.placeholder).toBe('Your name'); - expect(input.disabled).toBe(true); -}); - -it('forwards extraProps verbatim', () => { - registry.register('text', TextRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.componentRef.setInput('extraProps', { hint: 'be brief' }); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.hint').textContent).toBe( - 'be brief' - ); -}); - -it('syncs prop changes to an already-rendered instance', () => { - registry.register('text', TextRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.componentRef.setInput('value', 'first'); - fixture.detectChanges(); - - fixture.componentRef.setInput('value', 'second'); - fixture.detectChanges(); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - expect(input.value).toBe('second'); -}); - -it('emits valueChange and onChange when the renderer emits valueChange', () => { - registry.register('text', TextRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.detectChanges(); - - const seen: unknown[] = []; - const legacy: unknown[] = []; - fixture.componentInstance.valueChange.subscribe((v) => seen.push(v)); - fixture.componentInstance.onChange.subscribe((v) => legacy.push(v)); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - input.value = 'typed'; - input.dispatchEvent(new Event('input')); - - expect(seen).toEqual(['typed']); - expect(legacy).toEqual(['typed']); -}); - -it('binds the legacy onValueChange output name', () => { - registry.register('text', LegacyOutputRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.detectChanges(); - - const seen: unknown[] = []; - fixture.componentInstance.valueChange.subscribe((v) => seen.push(v)); - - fixture.nativeElement.querySelector('button.legacy-btn').click(); - - expect(seen).toEqual(['legacy']); -}); - -it('renders a plain function renderer as fallback HTML', () => { - registry.register('text', fallbackRenderer as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.componentRef.setInput('label', 'Name'); - fixture.componentRef.setInput('value', 'Ada'); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.fallback').textContent).toBe( - 'Name:Ada' - ); -}); - -it('renders an error for an unknown field type', () => { - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'nope'); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain( - 'Unknown field type: nope' - ); -}); - -it('re-renders when type changes', () => { - registry.register('text', TextRendererComponent as never); - registry.register('number', fallbackRenderer as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('input.txt')).not.toBeNull(); - - fixture.componentRef.setInput('type', 'number'); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('input.txt')).toBeNull(); - expect(fixture.nativeElement.querySelector('.fallback')).not.toBeNull(); -}); - -it('unsubscribes from renderer outputs on destroy', () => { - registry.register('text', TextRendererComponent as never); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.detectChanges(); - - const seen: unknown[] = []; - fixture.componentInstance.valueChange.subscribe((v) => seen.push(v)); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - fixture.destroy(); - - input.value = 'after destroy'; - input.dispatchEvent(new Event('input')); - - expect(seen).toEqual([]); -}); -``` - -- [ ] **Step 2: Run the tests** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- DynamicInput` -Expected: 10 passed. - -If `unsubscribes from renderer outputs on destroy` fails because the detached DOM node no longer fires, assert on the subscription count instead: capture `fixture.componentInstance` before destroy and assert that a post-destroy `emitValue` produces nothing. Do not weaken the test to a tautology. - -- [ ] **Step 3: Commit** - -```bash -git add packages/angular/test/DynamicInput.spec.ts -git commit -m "test(angular): cover DynamicInput rendering, prop sync, outputs, cleanup" -``` - ---- - -### Task 5: Cover `MultiFieldInput` - -**Files:** - -- Create: `packages/angular/test/MultiFieldInput.spec.ts` - -**Interfaces:** - -- Consumes: `TextRendererComponent`, `makeRegistry` from Task 2. -- `MultiFieldInput` inputs: `fieldDescriptions: FieldDescription[]`, `properties?: Properties`, `layout: LayoutConfig`, `rootData?: Properties`. Outputs: `onChange: EventEmitter`, `validityChange: EventEmitter`. - -- [ ] **Step 1: Write the tests** - -Create `packages/angular/test/MultiFieldInput.spec.ts`: - -```ts -import { TestBed } from '@angular/core/testing'; -import type { - FieldDescription, - ValidationResult, -} from '@dynamic-field-kit/core'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { MultiFieldInput } from '../src/components/MultiFieldInput'; -import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; -import { makeRegistry, TextRendererComponent } from './helpers/renderers'; - -describe('MultiFieldInput', () => { - let registry: ReturnType; - - beforeEach(() => { - registry = makeRegistry(); - registry.register('text', TextRendererComponent as never); - TestBed.configureTestingModule({ - imports: [MultiFieldInput], - providers: [{ provide: FIELD_REGISTRY, useValue: registry }], - }); - }); - - function mount( - fields: FieldDescription[], - properties: Record - ) { - const fixture = TestBed.createComponent(MultiFieldInput); - fixture.componentRef.setInput('fieldDescriptions', fields); - fixture.componentRef.setInput('properties', properties); - fixture.detectChanges(); - return fixture; - } - - it('renders one input per field', () => { - const fixture = mount( - [ - { name: 'first', type: 'text' }, - { name: 'last', type: 'text' }, - ], - { first: 'Ada', last: 'Lovelace' } - ); - - const inputs: HTMLInputElement[] = Array.from( - fixture.nativeElement.querySelectorAll('input.txt') - ); - expect(inputs.map((i) => i.value)).toEqual(['Ada', 'Lovelace']); - }); - - it('hides fields whose appearCondition is false', () => { - const fields: FieldDescription[] = [ - { name: 'kind', type: 'text' }, - { - name: 'company', - type: 'text', - appearCondition: (data) => data['kind'] === 'business', - }, - ]; - - expect( - mount(fields, { kind: 'personal' }).nativeElement.querySelectorAll( - 'input.txt' - ).length - ).toBe(1); - expect( - mount(fields, { kind: 'business' }).nativeElement.querySelectorAll( - 'input.txt' - ).length - ).toBe(2); - }); - - it('applies computeValue to the data it emits', () => { - const fields: FieldDescription[] = [ - { name: 'price', type: 'text' }, - { - name: 'total', - type: 'text', - computeValue: (data) => `${Number(data['price']) * 2}`, - }, - ]; - - const fixture = mount(fields, { price: '5' }); - - const inputs: HTMLInputElement[] = Array.from( - fixture.nativeElement.querySelectorAll('input.txt') - ); - expect(inputs[1].value).toBe('10'); - }); - - it('emits onChange with the updated data when a field changes', () => { - const fixture = mount([{ name: 'first', type: 'text' }], { first: 'Ada' }); - - const seen: unknown[] = []; - fixture.componentInstance.onChange.subscribe((d) => seen.push(d)); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - input.value = 'Grace'; - input.dispatchEvent(new Event('input')); - - expect(seen).toEqual([{ first: 'Grace' }]); - }); - - it('passes validation errors down to the renderer', () => { - const fixture = mount( - [ - { - name: 'email', - type: 'text', - validate: (value) => - String(value).includes('@') ? undefined : 'Invalid email', - }, - ], - { email: 'nope' } - ); - - expect(fixture.nativeElement.querySelector('.err').textContent).toBe( - 'Invalid email' - ); - }); - - it('resolves disabledCondition and readOnlyCondition', () => { - const fixture = mount( - [ - { - name: 'a', - type: 'text', - disabledCondition: (data) => data['frozen'] === true, - }, - { - name: 'b', - type: 'text', - readOnlyCondition: (data) => data['frozen'] === true, - }, - ], - { frozen: true } - ); - - const inputs: HTMLInputElement[] = Array.from( - fixture.nativeElement.querySelectorAll('input.txt') - ); - expect(inputs[0].disabled).toBe(true); - expect(inputs[1].readOnly).toBe(true); - }); - - it('does not report errors for disabled fields', () => { - const fixture = mount( - [ - { - name: 'email', - type: 'text', - disabled: true, - validate: () => 'Invalid email', - }, - ], - { email: 'nope' } - ); - - expect(fixture.nativeElement.querySelector('.err')).toBeNull(); - }); - - it('emits validityChange on init and on every change', () => { - const seen: ValidationResult[] = []; - const fixture = TestBed.createComponent(MultiFieldInput); - fixture.componentInstance.validityChange.subscribe((r) => seen.push(r)); - fixture.componentRef.setInput('fieldDescriptions', [ - { - name: 'email', - type: 'text', - validate: (value: unknown) => - String(value).includes('@') ? undefined : 'Invalid email', - }, - ]); - fixture.componentRef.setInput('properties', { email: 'nope' }); - fixture.detectChanges(); - - expect(seen[seen.length - 1]).toEqual({ - valid: false, - errors: { email: ['Invalid email'] }, - }); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - input.value = 'ada@example.com'; - input.dispatchEvent(new Event('input')); - - expect(seen[seen.length - 1]).toEqual({ valid: true, errors: {} }); - }); - - it('renders a repeatable group item per entry and adds one on Add', () => { - const fields: FieldDescription[] = [ - { - name: 'contacts', - type: 'text', - label: 'Contacts', - fields: [{ name: 'email', type: 'text' }], - }, - ]; - - const fixture = mount(fields, { - contacts: [{ email: 'a@x.com' }, { email: 'b@x.com' }], - }); - - expect(fixture.nativeElement.querySelectorAll('input.txt').length).toBe(2); - - const seen: unknown[] = []; - fixture.componentInstance.onChange.subscribe((d) => seen.push(d)); - - const addBtn: HTMLButtonElement = Array.from( - fixture.nativeElement.querySelectorAll('button') - ).find((b) => b.textContent?.trim() === 'Add')!; - addBtn.click(); - fixture.detectChanges(); - - expect((seen[0] as Record)['contacts'].length).toBe(3); - }); - - it('removes a group item on Remove', () => { - const fields: FieldDescription[] = [ - { - name: 'contacts', - type: 'text', - fields: [{ name: 'email', type: 'text' }], - }, - ]; - - const fixture = mount(fields, { - contacts: [{ email: 'a@x.com' }, { email: 'b@x.com' }], - }); - - const seen: unknown[] = []; - fixture.componentInstance.onChange.subscribe((d) => seen.push(d)); - - const removeBtn: HTMLButtonElement = Array.from( - fixture.nativeElement.querySelectorAll('button') - ).find((b) => b.textContent?.trim() === 'Remove')!; - removeBtn.click(); - fixture.detectChanges(); - - expect((seen[0] as Record)['contacts']).toEqual([ - { email: 'b@x.com' }, - ]); - }); - - it('applies the grid layout', () => { - const fixture = TestBed.createComponent(MultiFieldInput); - fixture.componentRef.setInput('fieldDescriptions', [ - { name: 'a', type: 'text' }, - ]); - fixture.componentRef.setInput('properties', { a: '1' }); - fixture.componentRef.setInput('layout', { type: 'grid', columns: 3 }); - fixture.detectChanges(); - - const container: HTMLElement = fixture.nativeElement.firstElementChild; - expect(container.style.gridTemplateColumns).toBe('repeat(3, 1fr)'); - }); -}); -``` - -- [ ] **Step 2: Run the tests** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- MultiFieldInput` -Expected: 12 passed. - -Note on the group tests: the nested `` emits its own `validityChange`/`onChange`, so `seen[0]` is the first emission from the **outer** component only because the subscription is on the outer instance. If a group test fails on emission ordering, assert with `seen[seen.length - 1]` rather than deleting the assertion. - -- [ ] **Step 3: Commit** - -```bash -git add packages/angular/test/MultiFieldInput.spec.ts -git commit -m "test(angular): cover MultiFieldInput fields, conditions, groups, validity" -``` - ---- - -### Task 6: Cover `FieldInput` and `BaseInput` - -**Files:** - -- Create: `packages/angular/test/FieldInput.spec.ts` - -**Interfaces:** - -- Consumes: `TextRendererComponent`, `makeRegistry` from Task 2. -- `FieldInput` inputs: `fieldDescription?: FieldDescription`, `value?: unknown`, `disabled?: boolean`, `readOnly?: boolean`, `error?: string | string[]`. Output: `onValueChangeField: EventEmitter<{ value: unknown; key: string }>`. - -- [ ] **Step 1: Write the tests** - -Create `packages/angular/test/FieldInput.spec.ts`: - -```ts -import { ChangeDetectorRef } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BaseInputComponent } from '../src/components/BaseInput'; -import { FieldInput } from '../src/components/FieldInput'; -import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; -import { makeRegistry, TextRendererComponent } from './helpers/renderers'; - -describe('FieldInput', () => { - let registry: ReturnType; - - beforeEach(() => { - registry = makeRegistry(); - registry.register('text', TextRendererComponent as never); - TestBed.configureTestingModule({ - imports: [FieldInput], - providers: [{ provide: FIELD_REGISTRY, useValue: registry }], - }); - }); - - it('renders nothing without a fieldDescription', () => { - const fixture = TestBed.createComponent(FieldInput); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('input.txt')).toBeNull(); - }); - - it('renders the field described by fieldDescription', () => { - const fixture = TestBed.createComponent(FieldInput); - fixture.componentRef.setInput('fieldDescription', { - name: 'first', - type: 'text', - placeholder: 'First name', - }); - fixture.componentRef.setInput('value', 'Ada'); - fixture.detectChanges(); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - expect(input.value).toBe('Ada'); - expect(input.placeholder).toBe('First name'); - }); - - it('emits onValueChangeField with the field name as key', () => { - const fixture = TestBed.createComponent(FieldInput); - fixture.componentRef.setInput('fieldDescription', { - name: 'first', - type: 'text', - }); - fixture.detectChanges(); - - const seen: unknown[] = []; - fixture.componentInstance.onValueChangeField.subscribe((e) => seen.push(e)); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - input.value = 'Grace'; - input.dispatchEvent(new Event('input')); - - expect(seen).toEqual([{ value: 'Grace', key: 'first' }]); - }); - - it('forwards error, disabled and readOnly to the renderer', () => { - const fixture = TestBed.createComponent(FieldInput); - fixture.componentRef.setInput('fieldDescription', { - name: 'email', - type: 'text', - }); - fixture.componentRef.setInput('error', ['Required', 'Invalid']); - fixture.componentRef.setInput('disabled', true); - fixture.componentRef.setInput('readOnly', true); - fixture.detectChanges(); - - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input.txt'); - expect(input.disabled).toBe(true); - expect(input.readOnly).toBe(true); - expect(fixture.nativeElement.querySelector('.err').textContent).toBe( - 'Required, Invalid' - ); - }); - - it('forwards FieldDescription.props as extraProps', () => { - const fixture = TestBed.createComponent(FieldInput); - fixture.componentRef.setInput('fieldDescription', { - name: 'first', - type: 'text', - props: { hint: 'keep it short' }, - }); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.hint').textContent).toBe( - 'keep it short' - ); - }); -}); - -describe('BaseInputComponent', () => { - class TestInput extends BaseInputComponent {} - - it('marks for check on input changes', () => { - const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef; - const input = new TestInput(cdr); - - input.ngOnChanges({}); - - expect(cdr.markForCheck).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 2: Run the tests** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- FieldInput` -Expected: 6 passed. - -- [ ] **Step 3: Commit** - -```bash -git add packages/angular/test/FieldInput.spec.ts -git commit -m "test(angular): cover FieldInput forwarding and BaseInput change detection" -``` - ---- - -### Task 7: Cover layout, the registry token, the module, and the public API - -**Files:** - -- Create: `packages/angular/test/layout.spec.ts` -- Create: `packages/angular/test/publicApi.spec.ts` - -**Interfaces:** - -- Consumes: `makeRegistry`, `TextRendererComponent` from Task 2. - -- [ ] **Step 1: Write the layout tests** - -Create `packages/angular/test/layout.spec.ts`: - -```ts -import { Component, TemplateRef, ViewChild } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - ColumnLayout, - GridLayout, - RowLayout, -} from '../src/layout/defaultLayouts'; -import { LayoutRegistry, layoutRegistry } from '../src/layout/layoutRegistry'; - -@Component({ - standalone: true, - imports: [ColumnLayout, RowLayout, GridLayout], - template: ` - x - - - - `, -}) -class LayoutHost { - @ViewChild('tpl', { static: true }) tpl!: TemplateRef; -} - -describe('LayoutRegistry', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('registers and retrieves a layout', () => { - const registry = new LayoutRegistry(); - registry.register('custom', ColumnLayout); - - expect(registry.get('custom')).toBe(ColumnLayout); - }); - - it('returns undefined for an unknown layout', () => { - expect(new LayoutRegistry().get('nope')).toBeUndefined(); - }); - - it('warns when a layout type is registered twice', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const registry = new LayoutRegistry(); - registry.register('custom', ColumnLayout); - registry.register('custom', RowLayout); - - expect(warn).toHaveBeenCalledTimes(1); - expect(registry.get('custom')).toBe(RowLayout); - }); - - it('exports a shared registry instance', () => { - expect(layoutRegistry).toBeInstanceOf(LayoutRegistry); - }); -}); - -describe('default layouts', () => { - it('renders the projected template with the configured gap', () => { - const fixture = TestBed.createComponent(LayoutHost); - fixture.detectChanges(); - - const column: HTMLElement = fixture.nativeElement.querySelector( - 'dfk-column-layout > div' - ); - expect(column.style.flexDirection).toBe('column'); - expect(column.style.gap).toBe('20px'); - expect(column.querySelector('.child')).not.toBeNull(); - }); - - it('defaults the gap to 12px', () => { - const fixture = TestBed.createComponent(LayoutHost); - fixture.detectChanges(); - - const row: HTMLElement = fixture.nativeElement.querySelector( - 'dfk-row-layout > div' - ); - expect(row.style.flexDirection).toBe('row'); - expect(row.style.gap).toBe('12px'); - }); - - it('renders the grid layout with the configured column count', () => { - const fixture = TestBed.createComponent(LayoutHost); - fixture.detectChanges(); - - const grid: HTMLElement = fixture.nativeElement.querySelector( - 'dfk-grid-layout > div' - ); - expect(grid.style.display).toBe('grid'); - expect(grid.style.gridTemplateColumns).toBe('repeat(3, 1fr)'); - expect(grid.style.gap).toBe('12px'); - }); -}); -``` - -- [ ] **Step 2: Run the layout tests** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- layout` -Expected: 7 passed. - -Note: importing `src/layout/defaultLayouts.ts` runs its module-level -`layoutRegistry.register('column' | 'row' | 'grid', ...)` side effects against -the shared `layoutRegistry`. The registry tests above use `new LayoutRegistry()` -precisely so those side effects cannot make them flaky. - -- [ ] **Step 3: Write the public API tests** - -Create `packages/angular/test/publicApi.spec.ts`: - -```ts -import { Component } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { FieldRegistry, fieldRegistry } from '@dynamic-field-kit/core'; -import { describe, expect, it } from 'vitest'; -import * as publicApi from '../src/public-api'; -import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; -import { DynamicFieldKitModule } from '../src/lib/dynamic-field-kit.module'; -import { makeRegistry, TextRendererComponent } from './helpers/renderers'; - -describe('public API', () => { - it('exports the components, registry and validation helpers', () => { - expect(publicApi.DynamicInput).toBeDefined(); - expect(publicApi.FieldInput).toBeDefined(); - expect(publicApi.MultiFieldInput).toBeDefined(); - expect(publicApi.FIELD_REGISTRY).toBe(FIELD_REGISTRY); - expect(publicApi.FieldRegistry).toBe(FieldRegistry); - expect(typeof publicApi.validateField).toBe('function'); - expect(typeof publicApi.validateFields).toBe('function'); - expect(typeof publicApi.resolveDisabled).toBe('function'); - expect(typeof publicApi.resolveReadOnly).toBe('function'); - }); -}); - -describe('FIELD_REGISTRY token', () => { - it('defaults to the process-wide singleton', () => { - TestBed.configureTestingModule({}); - - expect(TestBed.inject(FIELD_REGISTRY)).toBe(fieldRegistry); - }); - - it('can be overridden with a scoped registry', () => { - const scoped = makeRegistry(); - TestBed.configureTestingModule({ - providers: [{ provide: FIELD_REGISTRY, useValue: scoped }], - }); - - expect(TestBed.inject(FIELD_REGISTRY)).toBe(scoped); - expect(TestBed.inject(FIELD_REGISTRY)).not.toBe(fieldRegistry); - }); -}); - -describe('DynamicFieldKitModule', () => { - @Component({ - standalone: true, - imports: [DynamicFieldKitModule], - template: ``, - }) - class ModuleHost {} - - it('exports the components for template use', () => { - const scoped = makeRegistry(); - scoped.register('text', TextRendererComponent as never); - TestBed.configureTestingModule({ - providers: [{ provide: FIELD_REGISTRY, useValue: scoped }], - }); - - const fixture = TestBed.createComponent(ModuleHost); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('input.txt')).not.toBeNull(); - }); -}); -``` - -- [ ] **Step 4: Run the public API tests** - -Run: `npm run test --workspace=@dynamic-field-kit/angular -- publicApi` -Expected: 4 passed. - -- [ ] **Step 5: Commit** - -```bash -git add packages/angular/test/layout.spec.ts packages/angular/test/publicApi.spec.ts -git commit -m "test(angular): cover layouts, FIELD_REGISTRY token, module and public API" -``` - ---- - -### Task 8: Wire CI and verify everything - -**Files:** - -- Modify: `.github/workflows/ci.yml:59-79` - -**Interfaces:** - -- Consumes: the `test` script from Task 1. - -- [ ] **Step 1: Update the angular matrix entry** - -In `.github/workflows/ci.yml`, the angular entry currently reads: - -```yaml -- package: angular - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test:coverage --workspace=@dynamic-field-kit/angular - coverage-file: packages/angular/coverage/lcov.info - coverage-name: angular - needs-chrome: true -``` - -Replace it with: - -```yaml -- 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 -``` - -- [ ] **Step 2: Delete the Chrome install step** - -Remove this step entirely — no matrix entry sets `needs-chrome` any more: - -```yaml -- name: Install Chrome - if: matrix.needs-chrome == true - uses: browser-actions/setup-chrome@latest - with: - chrome-version: stable -``` - -- [ ] **Step 3: Confirm no `needs-chrome` references remain** - -Run: `grep -rn "needs-chrome\|setup-chrome\|karma\|jasmine" .github/workflows/ci.yml packages/angular/package.json` -Expected: no output. - -- [ ] **Step 4: Run the full Angular suite with coverage, as CI will** - -```bash -npm run build --workspace=@dynamic-field-kit/core -npm run test --workspace=@dynamic-field-kit/angular -- --coverage -``` - -Expected: all tests pass and the coverage summary shows real percentages for `src/`, not `Unknown% (0/0)`. Confirm `packages/angular/coverage/lcov.info` exists and is non-empty. - -- [ ] **Step 5: Build all four packages** - -```bash -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 -``` - -Expected: all four exit 0. The Angular build must still succeed after the `isComponentType` change. - -- [ ] **Step 6: Lint and format-check** - -```bash -npx prettier --write "packages/angular/**/*.{ts,json}" "docs/superpowers/**/*.md" -npm run lint -npm run format-check -``` - -Expected: lint exits 0; format-check reports "All matched files use Prettier code style!". - -- [ ] **Step 7: Run every package's tests** - -```bash -(cd packages/core && npx vitest run) -(cd packages/react && npx vitest run) -(cd packages/vue && npx vitest run) -npm run test --workspace=@dynamic-field-kit/angular -``` - -Expected: core 55 passed, react 59 passed, vue 58 passed, angular all passed. The Angular count is far below the old 36 — that is the intended outcome. - -- [ ] **Step 8: Run the verify scripts from the repo root** - -```bash -node scripts/verify-framework-deps.js -node scripts/check-cross-framework-imports.js -node scripts/integration-cross-registry.js -``` - -Expected: each prints its OK/passed message and exits 0. Run from the repo root — these resolve paths relative to the current directory. - -- [ ] **Step 9: Commit** - -```bash -git add .github/workflows/ci.yml -git add -A -git commit -m "ci(angular): drop Chrome, run vitest with coverage - -jsdom needs no browser, so angular stops being the one matrix entry that -installs Chrome. test-cmd now matches react and vue, and coverage reports -real numbers instead of Unknown% (0/0) — karma-coverage had nothing to -instrument because no spec imported the source." -``` - ---- - -### Task 9: Update the test backlog memory - -**Files:** none in the repo (memory only). - -- [ ] **Step 1: Update the memory file** - -`C:\Users\vance\.claude\projects\C--Git-dynamic-field-kit\memory\project_test_improvement_backlog.md` lists this work as items 1 and 2 and records the now-disproven diagnosis that karma "cannot import the CommonJS `@dynamic-field-kit/core`". Update it: - -- Mark items 1 and 2 done, with the date and the branch name. -- Correct the root cause: karma-typescript could not bundle **any** import; core's format was never the problem. -- Keep items 3-6 as the remaining backlog. -- Item 4 (vue `test` script watch-mode) is untouched by this cycle and stays open. - -Also update `C:\Users\vance\.claude\projects\C--Git-dynamic-field-kit\memory\project_ci_gates_and_test_gotchas.md`: the Angular gate is now `npm run test --workspace=@dynamic-field-kit/angular` (vitest, no Chrome), and Gotcha 1 (vue watch mode) still stands. - ---- - -## Notes for the implementer - -- Angular 19's `fixture.componentRef.setInput(name, value)` triggers `ngOnChanges` properly; plain property assignment does not. Always use `setInput` for `@Input()`s, then `fixture.detectChanges()`. -- `MultiFieldInput` is `OnPush` and imports itself for recursive group rendering. If a group assertion sees stale DOM, call `fixture.detectChanges()` again after the click rather than reaching for `fixture.autoDetectChanges()`. -- The registry is provided per test via `FIELD_REGISTRY`. Never touch the module-level `fieldRegistry` singleton in a spec except in `publicApi.spec.ts`, which asserts the token defaults to it. -- `registry.register('text', X as never)` is the cast used throughout: `FieldRegistry.register` is typed for function renderers, and Angular component classes are passed through it by design (the README uses `as any`). -- If a test is hard to write, that is a signal about the code, not a reason to weaken the test. Never assert a tautology — the suite this replaces was 36 of them. - -## Corrections (post-implementation) - -- `tsconfig.spec.json` must NOT be deleted, contrary to what this plan says: `@analogjs/vite-plugin-angular` resolves it for the test compile, and without it every spec file fails to compile with "No test suite found". It was kept and aligned to `target: ES2022` + `useDefineForClassFields: true` to match the shipped fesm2022 emit. -- `@angular-devkit/build-angular` is an OPTIONAL peer of the analog plugin, not a required install as the plan implies; it auto-installs regardless, and removing it does not help. -- The destroy test specified in Task 4 could not fail (Angular's own teardown removes the DOM listener, so the assertion passed whether or not `DynamicInput` unsubscribed). It was rewritten to emit directly on the renderer instance. -- Test counts in the plan are wrong (Task 5 Step 2 says 12 but its code block has 11 `it()` blocks; Task 8 predicts the Angular count drops "far below 36"). Final real state: **43 tests across 6 files**, above the old 36. -- Scope was extended with the owner's approval beyond the plan's "only `isComponentType` may change": `applyProps` (and its `supplied` gate) in `DynamicInput.ts` were also fixed, after the first mounted test proved a second production bug. diff --git a/docs/superpowers/plans/2026-07-19-type-tests-and-ci-typecheck.md b/docs/superpowers/plans/2026-07-19-type-tests-and-ci-typecheck.md deleted file mode 100644 index 03324cb..0000000 --- a/docs/superpowers/plans/2026-07-19-type-tests-and-ci-typecheck.md +++ /dev/null @@ -1,537 +0,0 @@ -# Type Tests + CI Typecheck Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make core's type tests fail on real type regressions (vitest typecheck), and give the repo a working, CI-wired `npm run typecheck` while deleting the broken `scripts/typecheck-all.js`. - -**Architecture:** Add a `packages/core/test/types.test-d.ts` exercised by vitest's typecheck mode (`vitest typecheck --run`), with positive `assertType`/`expectTypeOf` and negative `@ts-expect-error` cases. Replace the bespoke `typecheck-all.js` with per-package `typecheck` scripts (`tsc --noEmit`) fanned out by `npm run typecheck --workspaces --if-present`. Wire both into the CI `lint-and-build` job. - -**Tech Stack:** vitest `0.34.6` typecheck mode (already installed in `packages/core`), TypeScript `tsc --noEmit`, npm workspaces, GitHub Actions. - -Spec: `docs/superpowers/specs/2026-07-19-type-tests-and-ci-typecheck-design.md` - -## Global Constraints - -- Work on branch `feat/type-tests-and-ci-typecheck`, based on `develop`. It already exists and is checked out. -- Never append a `Co-Authored-By: Claude ...` trailer or any Claude attribution to commits, and no "Generated with Claude Code" footer in any PR body. End the commit message at the last content line. -- The vitest typecheck command on 0.34.6 is exactly `vitest typecheck --run` (the `--typecheck` CLI flag does NOT exist in 0.34.6; it throws `Unknown option --typecheck`). vitest auto-detects `test/**/*.test-d.ts` in typecheck mode; no `vitest.config` change is needed for detection. -- Every `tsc` typecheck MUST pass `--noEmit`. `packages/core/tsconfig.json` sets neither `noEmit` nor `outDir`, so without `--noEmit` tsc emits `.js`/`.d.ts` into `src`. After any typecheck run, `git status` must be clean. -- Angular gets NO `typecheck` script: ng-packagr already type-checks Angular `src` at build time, and raw `tsc` cannot compile Angular decorators/templates. `--if-present` skips it. -- `react`/`vue` typecheck resolves `@dynamic-field-kit/core` types from the workspace symlink to core's built `dist`. Build core (`npm run build --workspace=@dynamic-field-kit/core`) before running `npm run typecheck`. -- Do NOT change the `FieldDescription` / `FieldRendererProps` / `FieldTypeMap` types in `src`. This work only observes them. -- Prettier governs all files: run `npm run format-check` before every commit. Lint gate: `npm run lint`. -- Baseline confirmed before starting: `core`, `react`, `vue` all pass `tsc -p tsconfig.json --noEmit` today with no emit; there are no pre-existing latent type errors to fix. - ---- - -### Task 1: Real type tests for core's public types - -**Files:** - -- Create: `packages/core/test/types.test-d.ts` -- Modify: `packages/core/package.json` (add `test:types` script) - -**Interfaces:** - -- Consumes: the exported types `Properties`, `FieldRendererProps`, `FieldDescription`, `FieldTypeKey`, `FieldTypeMap` from `packages/core/src`. -- Produces: `npm run test:types --workspace=@dynamic-field-kit/core` running `vitest typecheck --run`. Task 4 (CI) depends on this script name. - -**Context:** vitest strips types with esbuild, so the current runtime `types.test.ts` never fails on a type regression and has zero negative assertions. This task adds compile-time-checked assertions. Type-test development is a TDD loop: write assertions, run `vitest typecheck --run`, and adjust each assertion to the _actual_ resolved type where the compiler disagrees — an assertion the compiler rejects for the wrong reason is a bug in the test, not the type. Never delete a negative case to make the run green; a failing `@ts-expect-error` means the directive is unused (the code compiled) — that is a real finding. - -- [ ] **Step 1: Add the `test:types` script** - -In `packages/core/package.json`, add this script alongside the existing `test` script: - -```json - "test:types": "vitest typecheck --run", -``` - -- [ ] **Step 2: Write the type tests** - -Create `packages/core/test/types.test-d.ts`: - -```ts -import { assertType, expectTypeOf, test } from 'vitest'; -import type { - FieldDescription, - FieldRendererProps, - FieldTypeMap, - Properties, -} from '../src'; - -// Apps augment FieldTypeMap to register field types; the augmentation must -// still resolve. This mirrors how a consuming app extends the interface. -declare module '../src' { - interface FieldTypeMap { - customType: { id: string }; - } -} - -test('Properties is a string-keyed record of unknown', () => { - expectTypeOf().toEqualTypeOf>(); - assertType({ a: 1, b: 'x', c: true, d: { nested: true } }); - assertType({}); -}); - -test('FieldRendererProps narrows value to its type parameter', () => { - expectTypeOf['value']>().toEqualTypeOf< - string | undefined - >(); - expectTypeOf['value']>().toEqualTypeOf< - number | undefined - >(); - - assertType>({ - value: 'test', - label: 'Label', - placeholder: 'Enter value', - required: true, - disabled: false, - readOnly: false, - error: ['bad'], - options: [{ label: 'Option 1' }], - className: 'c', - description: 'help', - onValueChange: (v) => expectTypeOf(v).toEqualTypeOf(), - }); - - assertType({}); -}); - -test('FieldRendererProps rejects a value of the wrong type', () => { - // @ts-expect-error - value must be a number for FieldRendererProps - assertType>({ value: 'not a number' }); - - // @ts-expect-error - onValueChange must accept a number, not a string - const cb: FieldRendererProps['onValueChange'] = (v: string) => v; - void cb; -}); - -test('FieldDescription accepts a minimal and a fully-populated shape', () => { - assertType({ name: 'username', type: 'text' }); - - assertType({ - name: 'email', - type: 'text', - label: 'Email', - placeholder: 'Enter email', - required: true, - disabled: false, - className: 'c', - description: 'desc', - options: [{ label: 'o' }], - props: { maxLength: 5 }, - appearCondition: (data) => data.x === 1, - validate: (value) => (typeof value === 'string' ? undefined : 'bad'), - disabledCondition: (data) => data.locked === true, - readOnlyCondition: (data, rootData) => (rootData ?? data).frozen === true, - computeValue: (data) => data.a, - fields: [{ name: 'child', type: 'text' }], - defaultItem: {}, - keyField: 'id', - minItems: 0, - maxItems: 3, - addLabel: 'Add', - removeLabel: 'Remove', - }); -}); - -test('FieldDescription requires name and type of the right types', () => { - // @ts-expect-error - missing required 'type' - assertType({ name: 'x' }); - - // @ts-expect-error - missing required 'name' - assertType({ type: 'text' }); - - // @ts-expect-error - name must be a string - assertType({ name: 123, type: 'text' }); -}); - -test('validate must return string | string[] | undefined', () => { - // @ts-expect-error - validate may not return a number - const bad: FieldDescription['validate'] = () => 42; - void bad; - - const ok: FieldDescription['validate'] = () => ['e1', 'e2']; - void ok; -}); - -test('condition hooks return booleans', () => { - expectTypeOf< - NonNullable - >().returns.toEqualTypeOf(); - expectTypeOf< - NonNullable - >().returns.toEqualTypeOf(); -}); - -test('FieldTypeMap augmentation resolves', () => { - expectTypeOf().toEqualTypeOf<{ id: string }>(); - assertType({ name: 'c', type: 'customType' }); -}); -``` - -- [ ] **Step 3: Run the type tests — expect PASS** - -Run: `npm run test:types --workspace=@dynamic-field-kit/core` - -Expected: all tests pass, summary shows `Type Errors no errors`. If the compiler rejects a _positive_ assertion, the assertion's expected type is wrong — correct it to the real resolved type (e.g. adjust an `expectTypeOf(...).toEqualTypeOf<...>()` target). If a `@ts-expect-error` reports "Unused '@ts-expect-error' directive", the code it guards actually compiled — that means the type is looser than expected; keep the finding, and if it reveals the type genuinely accepts that input, convert the case to a positive `assertType` and note it in the commit message rather than deleting it. - -- [ ] **Step 4: Prove the tests are real — expect FAIL** - -Temporarily append a deliberately wrong assertion to the file: - -```ts -test('TEMP sanity — must fail', () => { - expectTypeOf().toEqualTypeOf(); -}); -``` - -Run: `npm run test:types --workspace=@dynamic-field-kit/core` -Expected: FAIL, `Type Errors 1 failed`, non-zero exit. This confirms the mechanism catches type errors (esbuild-stripped runtime tests never could). - -Then delete the `TEMP sanity` test and re-run: - -Run: `npm run test:types --workspace=@dynamic-field-kit/core` -Expected: PASS again. - -- [ ] **Step 5: Confirm normal test run ignores the type-test file** - -Run: `npm run test --workspace=@dynamic-field-kit/core` -Expected: the same test count as before this task (the runtime suite). `types.test-d.ts` is NOT collected — core's vitest `include` glob is `test/**/*.{test,spec}.{js,ts}`, which does not match `*.test-d.ts`. - -- [ ] **Step 6: Format and commit** - -```bash -npx prettier --write packages/core/test/types.test-d.ts packages/core/package.json -git add packages/core/test/types.test-d.ts packages/core/package.json -git commit -m "test(core): add real type tests via vitest typecheck - -The runtime types.test.ts is esbuild-stripped, so it never fails on a type -regression and had zero negative assertions. types.test-d.ts adds compile- -time assertions - positive assertType/expectTypeOf plus @ts-expect-error -negatives for missing name/type, wrong scalar types, and a bad validate -return - run by 'vitest typecheck --run' via the new test:types script." -``` - ---- - -### Task 2: Trim the runtime `types.test.ts` to real runtime tests - -**Files:** - -- Modify: `packages/core/test/types.test.ts` - -**Interfaces:** - -- Consumes: nothing new. Removes the tautological "construct a typed object, assert the value just assigned" cases whose type intent now lives in `types.test-d.ts` (Task 1). - -**Context:** The remaining tests must exercise _runtime_ behavior — app-supplied callbacks actually being invoked, and `Properties` key access — not type assignability. Keep those; drop the rest. - -- [ ] **Step 1: Replace the file contents** - -Overwrite `packages/core/test/types.test.ts` with exactly: - -```ts -import { describe, expect, test } from 'vitest'; -import type { FieldDescription, Properties } from '../src'; - -// Compile-time type guarantees live in types.test-d.ts. These tests only -// exercise runtime behavior: app-supplied callbacks being invoked, and -// Properties key access. - -describe('FieldDescription runtime callbacks', () => { - test('appearCondition is invoked with form data', () => { - const condition = (d: Properties) => d.showAdvanced === true; - const field: FieldDescription = { - name: 'advanced', - type: 'text', - appearCondition: condition, - }; - - expect(field.appearCondition?.({ showAdvanced: true })).toBe(true); - expect(field.appearCondition?.({ showAdvanced: false })).toBe(false); - }); - - test('complex appearCondition logic evaluates against nested data', () => { - const condition = (d: Properties) => - d.role === 'admin' && - (d.age as number) >= 18 && - (d.tags as string[]).includes('vip'); - - expect(condition({ role: 'admin', age: 25, tags: ['vip', 'active'] })).toBe( - true - ); - expect(condition({ role: 'user', age: 25, tags: ['vip'] })).toBe(false); - expect(condition({ role: 'admin', age: 15, tags: ['vip'] })).toBe(false); - }); - - test('validate, disabledCondition and readOnlyCondition are invoked', () => { - const field: FieldDescription = { - name: 'email', - type: 'text', - validate: (value) => - typeof value === 'string' && value.includes('@') - ? undefined - : 'Invalid email', - disabledCondition: (data) => data.locked === true, - readOnlyCondition: (data, rootData) => (rootData ?? data).frozen === true, - }; - - expect(field.validate?.('a', {}, {})).toBe('Invalid email'); - expect(field.validate?.('a@b', {}, {})).toBeUndefined(); - expect(field.disabledCondition?.({ locked: true })).toBe(true); - expect(field.readOnlyCondition?.({}, { frozen: true })).toBe(true); - }); -}); - -describe('Properties runtime access', () => { - test('supports special-character and mixed-style keys', () => { - const props: Properties = { - 'special-key': 'value', - camelCase: 'test', - snake_case: 'test', - }; - - expect(props['special-key']).toBe('value'); - expect(props['camelCase']).toBe('test'); - expect(props['snake_case']).toBe('test'); - }); -}); -``` - -- [ ] **Step 2: Run the core suite** - -Run: `npm run test --workspace=@dynamic-field-kit/core` -Expected: PASS. The `Types` describe block is gone; these four runtime tests replace it. Total core count drops by the removed tautologies — that is expected and not a regression. - -- [ ] **Step 3: Confirm core still type-checks (test files included)** - -Run: `(cd packages/core && npx tsc -p tsconfig.json --noEmit)` then `git status --short` -Expected: exit 0, and `git status` shows no emitted files. core's tsconfig `include`s `test`, so this also compiles `types.test.ts` and `types.test-d.ts`. - -- [ ] **Step 4: Format and commit** - -```bash -npx prettier --write packages/core/test/types.test.ts -git add packages/core/test/types.test.ts -git commit -m "test(core): reduce types.test.ts to real runtime assertions - -The type-assignability cases moved to types.test-d.ts, where they are -actually checked. What remains here invokes app-supplied callbacks -(appearCondition/validate/disabledCondition/readOnlyCondition) and exercises -Properties key access - behavior esbuild does not strip." -``` - ---- - -### Task 3: Repo-wide typecheck via npm workspaces - -**Files:** - -- Modify: `packages/core/package.json` (add `--noEmit` to `typecheck`) -- Modify: `packages/react/package.json` (add `typecheck` script) -- Modify: `packages/vue/package.json` (add `typecheck` script) -- Modify: `package.json` (root `typecheck` script) -- Delete: `scripts/typecheck-all.js` - -**Interfaces:** - -- Produces: `npm run typecheck` at the repo root that type-checks core, react, and vue with `--noEmit`, skipping angular. Task 4 (CI) depends on this. - -**Context:** `scripts/typecheck-all.js` calls `spawnSync('tsc', …)` with no shell, so on Windows it cannot launch `tsc.cmd` and exits 1 without checking. Replacing it with workspace scripts removes the bug by construction. Only the root `package.json` references the script today (CI does not). - -- [ ] **Step 1: Add `--noEmit` to core's typecheck** - -In `packages/core/package.json`, change the `typecheck` script from `tsc -p tsconfig.json` to: - -```json - "typecheck": "tsc -p tsconfig.json --noEmit", -``` - -- [ ] **Step 2: Add typecheck scripts to react and vue** - -In `packages/react/package.json` AND `packages/vue/package.json`, add: - -```json - "typecheck": "tsc -p tsconfig.json --noEmit", -``` - -- [ ] **Step 3: Point the root script at the workspaces** - -In the root `package.json`, change: - -```json - "typecheck": "node scripts/typecheck-all.js", -``` - -to: - -```json - "typecheck": "npm run typecheck --workspaces --if-present", -``` - -- [ ] **Step 4: Delete the broken script** - -```bash -git rm scripts/typecheck-all.js -``` - -- [ ] **Step 5: Build core, then run the repo typecheck** - -```bash -npm run build --workspace=@dynamic-field-kit/core -npm run typecheck -``` - -Expected: core, react, and vue each print `> tsc -p tsconfig.json --noEmit` and exit 0; angular is skipped (no script). Total exit 0. - -- [ ] **Step 6: Confirm nothing was emitted** - -Run: `git status --short` -Expected: only the four modified `package.json` files and the deleted `scripts/typecheck-all.js` — no `.js`/`.d.ts` under any `src` or `test` tree. - -- [ ] **Step 7: Prove typecheck catches a src regression — expect FAIL** - -Temporarily add a type error to `packages/react/src` — pick any exported `.ts` and add at the end: - -```ts -const _typecheckProbe: number = 'not a number'; -``` - -Run: `npm run typecheck` -Expected: FAIL with a TS2322 error in the react package, non-zero exit. Then remove the line and re-run: - -Run: `npm run typecheck` -Expected: exit 0. - -- [ ] **Step 8: Format and commit** - -```bash -npx prettier --write package.json packages/core/package.json packages/react/package.json packages/vue/package.json -git add package.json packages/core/package.json packages/react/package.json packages/vue/package.json -git add -u scripts -git commit -m "build: replace broken typecheck-all.js with workspace typecheck - -scripts/typecheck-all.js ran spawnSync('tsc') with no shell, so on Windows -it could not launch tsc.cmd and exited 1 without type-checking anything. Each -package now owns a 'tsc -p tsconfig.json --noEmit' typecheck script and the -root fans out with --workspaces --if-present (angular is skipped; ng-packagr -type-checks it at build time). --noEmit stops tsc emitting into src, since -core's tsconfig sets neither noEmit nor outDir." -``` - ---- - -### Task 4: Wire typecheck and type tests into CI - -**Files:** - -- Modify: `.github/workflows/ci.yml` - -**Interfaces:** - -- Consumes: root `npm run typecheck` (Task 3) and `npm run test:types --workspace=@dynamic-field-kit/core` (Task 1). - -**Context:** The `lint-and-build` job already runs `npm ci` and builds all four packages, so core's `dist` (which react/vue typecheck resolves against) is present. Add the two checks there as required steps. - -- [ ] **Step 1: Add the steps after "Build packages"** - -In `.github/workflows/ci.yml`, in the `lint-and-build` job, immediately after the `Build packages` step (the one that builds core/react/vue/angular) and before `Show bundle sizes`, insert: - -```yaml -- name: Typecheck - run: npm run typecheck - -- name: Type tests - run: npm run test:types --workspace=@dynamic-field-kit/core -``` - -- [ ] **Step 2: Validate the workflow YAML** - -Run: `node -e "const y=require('fs').readFileSync('.github/workflows/ci.yml','utf8'); if(!/name: Typecheck/.test(y)||!/npm run test:types/.test(y)) throw new Error('steps missing'); console.log('CI steps present')"` -Expected: `CI steps present`. - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: run typecheck and core type tests in lint-and-build - -The lint-and-build job already builds every package, so core's dist is -available for react/vue typecheck to resolve. Adds 'npm run typecheck' and -the core 'test:types' vitest typecheck run as required checks." -``` - ---- - -### Task 5: Full verification and backlog memory update - -**Files:** none in the repo (verification + agent memory only). - -- [ ] **Step 1: Run every CI gate locally, in order** - -```bash -npm run build --workspace=@dynamic-field-kit/core -npm run typecheck -npm run test:types --workspace=@dynamic-field-kit/core -npm run lint -npm run format-check -npm run build --workspace=@dynamic-field-kit/react -npm run build --workspace=@dynamic-field-kit/vue -npm run build --workspace=@dynamic-field-kit/angular -``` - -Expected: every command exits 0. `format-check` prints "All matched files use Prettier code style!". - -- [ ] **Step 2: Run all four package test suites** - -```bash -npm run test --workspace=@dynamic-field-kit/core -(cd packages/react && npx vitest run) -(cd packages/vue && npx vitest run) -npm run test --workspace=@dynamic-field-kit/angular -``` - -Expected: all pass. core's count is lower than before (Task 2 trimmed tautologies); react/vue/angular unchanged. - -- [ ] **Step 3: Run the three verify scripts from the repo root** - -```bash -node scripts/verify-framework-deps.js -node scripts/check-cross-framework-imports.js -node scripts/integration-cross-registry.js -``` - -Expected: each prints its OK/passed message and exits 0. - -- [ ] **Step 4: Confirm a clean tree** - -Run: `git status --short` -Expected: empty. No stray emitted files anywhere. - -- [ ] **Step 5: Update the backlog memory** - -Edit `C:\Users\vance\.claude\projects\C--Git-dynamic-field-kit\memory\project_test_improvement_backlog.md`: - -- Mark **item 3 DONE** (2026-07-19, branch `feat/type-tests-and-ci-typecheck`): real type tests via `vitest typecheck --run` in `packages/core/test/types.test-d.ts` (positive + `@ts-expect-error` negatives); `scripts/typecheck-all.js` deleted and replaced with per-package `tsc --noEmit` scripts fanned out by `npm run typecheck --workspaces --if-present`; both wired into the CI `lint-and-build` job. Note the Windows `spawnSync` bug is gone by construction, and `--noEmit` fixes the tsc-emits-into-src problem. -- Leave items 4, 5, 6, 7, 8 open. - -Then edit `C:\Users\vance\.claude\projects\C--Git-dynamic-field-kit\memory\project_ci_gates_and_test_gotchas.md`: - -- Add to the CI gates list: `npm run typecheck` (core/react/vue via `tsc --noEmit`; angular via its build) and core `npm run test:types` (`vitest typecheck --run`), both in the `lint-and-build` job. -- Add a gotcha: vitest `0.34.6` has no `--typecheck` CLI flag; the command is `vitest typecheck --run`, and type tests live in `*.test-d.ts` (not collected by the normal `vitest run`). - -- [ ] **Step 6: Finish the branch** - -Invoke the `superpowers:finishing-a-development-branch` skill to decide integration (PR into `develop` — see [[project_integration_branch_develop]]; the base is `develop`, not `master`). - ---- - -## Notes for the implementer - -- The vitest typecheck command is `vitest typecheck --run`. Do NOT use `vitest --typecheck` (throws `Unknown option --typecheck` on 0.34.6). -- Type-test TDD: write assertion → `npm run test:types` → adjust to the real type. A failing `@ts-expect-error` ("Unused directive") means the guarded code compiled — a real looseness finding, not something to silence by deleting the case. -- Always `--noEmit`. After any typecheck, `git status` must be clean; a stray `.d.ts`/`.js` under `src` means an emit slipped through. -- Angular is intentionally excluded from `npm run typecheck`; its build is its typecheck. Do not add a `typecheck` script to `packages/angular`. -- Build core before `npm run typecheck` so react/vue can resolve `@dynamic-field-kit/core` types from `dist`. diff --git a/docs/superpowers/plans/2026-07-22-published-package-render-smoke.md b/docs/superpowers/plans/2026-07-22-published-package-render-smoke.md deleted file mode 100644 index 0410cc0..0000000 --- a/docs/superpowers/plans/2026-07-22-published-package-render-smoke.md +++ /dev/null @@ -1,453 +0,0 @@ -# Published-Package Render Smoke Test Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a CI-blocking smoke suite that imports each adapter as a real consumer (bare specifier → built `dist`), mounts a component through the real framework runtime on jsdom, and asserts DOM output. - -**Architecture:** A new private `smoke/` workspace. Its tests import `@dynamic-field-kit/{core,react,vue}` by bare specifier, which resolves through each package's `exports` map to `dist` (verified: all three point only to `dist`). React uses `@testing-library/react`; Vue uses `@vue/test-utils`; both on jsdom. A vitest `globalSetup` preflight fails with a readable message if a `dist` is missing. Runs in the CI `verify` job, which already builds all packages first. - -**Tech Stack:** vitest 1.6, jsdom 24, `@vitejs/plugin-react` 4, `@testing-library/react` 16, `@vue/test-utils` 2.4, react/react-dom 19, vue 3.5. (Angular best-effort: `@angular/*` 19 + zone.js.) - -## Global Constraints - -- Integration branch is `develop`, NOT `master`. This work is on branch `feat/published-package-smoke` off `develop`. -- No Co-Authored-By trailer on commits; no "Generated with Claude Code" footer. -- Tests import the **bare package specifier** (`@dynamic-field-kit/react`), never a relative `../packages/...` or `dist` path — resolution to `dist` is the point. -- Smoke tests must NOT add a `coverage` block — they assert artifact behavior, not line coverage, and must not affect the per-package coverage floors. -- The `smoke/` workspace requires all packages built (`npm run build`) before it can run; the CI `verify` job already builds them. -- Assert DOM without `@testing-library/jest-dom` matchers (no setup file) — use `.textContent` / `wrapper.text()` and plain `expect(...).toBe(...)`. - ---- - -### Task 1: Scaffold the `smoke/` workspace + React render-smoke - -**Files:** - -- Create: `smoke/package.json` -- Create: `smoke/vitest.config.ts` -- Create: `smoke/globalSetup.ts` -- Create: `smoke/react.smoke.test.tsx` -- Modify: `package.json` (root) — add `"smoke"` to `workspaces` - -**Interfaces:** - -- Consumes: from `@dynamic-field-kit/react` (built dist) — `DynamicInput` (default-exported component, prop `type: string`, `value?: unknown`), `FieldRegistryProvider` (props `registry`, `children`), `FieldRegistry` (class with `register(type, renderer)`). -- Produces: the `@dynamic-field-kit/smoke` workspace with `scripts.test = "vitest run"`; `smoke/globalSetup.ts` default-exports a `() => void` dist preflight reused by later tasks. - -- [ ] **Step 1: Register the workspace** - -Modify root `package.json` `workspaces` from `["packages/*"]` to: - -```json - "workspaces": [ - "packages/*", - "smoke" - ], -``` - -- [ ] **Step 2: Create `smoke/package.json`** - -```json -{ - "name": "@dynamic-field-kit/smoke", - "version": "0.0.0", - "private": true, - "description": "Render smoke tests against the built (dist) packages, as a real consumer.", - "scripts": { - "test": "vitest run" - }, - "dependencies": { - "@dynamic-field-kit/core": "*", - "@dynamic-field-kit/react": "*", - "@dynamic-field-kit/vue": "*" - }, - "devDependencies": { - "@testing-library/dom": "^10.0.0", - "@testing-library/react": "^16.0.0", - "@vitejs/plugin-react": "^4.2.0", - "@vue/test-utils": "^2.4.6", - "jsdom": "^24.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "vitest": "^1.6.0", - "vue": "^3.5.0" - } -} -``` - -- [ ] **Step 3: Create `smoke/globalSetup.ts` (dist preflight)** - -```ts -import { existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// Runs once before any smoke test file loads. A static `import` from a package -// whose dist is missing would otherwise fail with an opaque resolution stack; -// this turns it into a readable "build first" error. -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -const requiredArtifacts = [ - 'packages/core/dist/index.js', - 'packages/react/dist/index.mjs', - 'packages/vue/dist/index.js', -]; - -export default function globalSetup(): void { - const missing = requiredArtifacts.filter( - (rel) => !existsSync(resolve(repoRoot, rel)) - ); - if (missing.length > 0) { - throw new Error( - `Missing build artifact(s): ${missing.join(', ')}. ` + - `Run "npm run build" (all packages) before the smoke tests.` - ); - } -} -``` - -- [ ] **Step 4: Create `smoke/vitest.config.ts`** - -```ts -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [react()], - test: { - globals: true, - environment: 'jsdom', - globalSetup: ['./globalSetup.ts'], - include: ['*.smoke.test.{ts,tsx}'], - }, -}); -``` - -- [ ] **Step 5: Write the React smoke test `smoke/react.smoke.test.tsx`** - -```tsx -import { - DynamicInput, - FieldRegistry, - FieldRegistryProvider, -} from '@dynamic-field-kit/react'; -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -// Augment so registry.register('text', …) is well-typed for editors/tsc. -declare module '@dynamic-field-kit/core' { - interface FieldTypeMap { - text: string; - } -} - -const TextRenderer = ({ value }: { value?: string }) => ( -
{value}
-); - -describe('react built package renders', () => { - it('mounts a DynamicInput from the built dist and renders its value', () => { - const registry = new FieldRegistry(); - registry.register('text', TextRenderer as never); - - render( - - - - ); - - expect(screen.getByTestId('smoke').textContent).toBe('hi'); - }); -}); -``` - -- [ ] **Step 6: Install so the new workspace is linked** - -Run: `npm install` -Expected: completes; `@dynamic-field-kit/smoke` appears under workspaces (no error about an unknown workspace). - -- [ ] **Step 7: Build the packages the smoke needs** - -Run: `npm run build --workspace=@dynamic-field-kit/core && npm run build --workspace=@dynamic-field-kit/react` -Expected: both builds succeed; `packages/react/dist/index.mjs` exists. - -- [ ] **Step 8: Run the React smoke — verify it passes** - -Run: `npm run test --workspace=@dynamic-field-kit/smoke` -Expected: PASS — `react.smoke.test.tsx (1 test)`, 1 passed. - -- [ ] **Step 9: Prove it bites (temporary break)** - -Run: `mv packages/react/dist/index.mjs packages/react/dist/index.mjs.bak && npm run test --workspace=@dynamic-field-kit/smoke; mv packages/react/dist/index.mjs.bak packages/react/dist/index.mjs` -Expected: FAIL with the preflight message "Missing build artifact(s): packages/react/dist/index.mjs …"; after the `mv` back, the file is restored. - -- [ ] **Step 10: Commit** - -```bash -git add smoke package.json package-lock.json -git commit -m "test(smoke): render React DynamicInput from built dist" -``` - ---- - -### Task 2: Vue render-smoke - -**Files:** - -- Create: `smoke/vue.smoke.test.ts` - -**Interfaces:** - -- Consumes: from `@dynamic-field-kit/vue` (built dist) — `DynamicInput` (default component, props `type`, `value`), `FieldRegistryKey` (vue `InjectionKey`), `FieldRegistry` (class). Uses `@vue/test-utils` `mount` with `global.provide` (the DI mechanism from item 6). -- Produces: `smoke/vue.smoke.test.ts`, matched by the existing `*.smoke.test.{ts,tsx}` include. - -- [ ] **Step 1: Write the Vue smoke test `smoke/vue.smoke.test.ts`** - -```ts -import { - DynamicInput, - FieldRegistry, - FieldRegistryKey, -} from '@dynamic-field-kit/vue'; -import { mount } from '@vue/test-utils'; -import { describe, expect, it } from 'vitest'; - -declare module '@dynamic-field-kit/core' { - interface FieldTypeMap { - text: string; - } -} - -const TextRenderer = { - props: ['value'], - template: '
{{ value }}
', -}; - -describe('vue built package renders', () => { - it('mounts a DynamicInput from the built dist and renders its value', () => { - const registry = new FieldRegistry(); - registry.register('text', TextRenderer as never); - - const wrapper = mount(DynamicInput, { - props: { type: 'text', value: 'hi' }, - global: { provide: { [FieldRegistryKey]: registry } }, - }); - - expect(wrapper.get('[data-testid="smoke"]').text()).toBe('hi'); - }); -}); -``` - -- [ ] **Step 2: Build the vue package** - -Run: `npm run build --workspace=@dynamic-field-kit/vue` -Expected: succeeds; `packages/vue/dist/index.js` exists. - -- [ ] **Step 3: Run the smoke suite — verify both pass** - -Run: `npm run test --workspace=@dynamic-field-kit/smoke` -Expected: PASS — 2 test files (`react.smoke.test.tsx`, `vue.smoke.test.ts`), 2 passed. - -- [ ] **Step 4: Prove the Vue path bites** - -Run: `mv packages/vue/dist/index.js packages/vue/dist/index.js.bak && npm run test --workspace=@dynamic-field-kit/smoke; mv packages/vue/dist/index.js.bak packages/vue/dist/index.js` -Expected: FAIL with preflight "Missing build artifact(s): packages/vue/dist/index.js …"; file restored afterward. - -- [ ] **Step 5: Commit** - -```bash -git add smoke/vue.smoke.test.ts -git commit -m "test(smoke): render Vue DynamicInput from built dist" -``` - ---- - -### Task 3: Angular render-smoke (best-effort) - -**Files:** - -- Create (only if it works cleanly): `smoke/angular.smoke.test.ts` -- Modify (only if pursued): `smoke/package.json` (add angular deps), `smoke/globalSetup.ts` (add angular artifact), `smoke/vitest.config.ts` (no change expected) - -**Interfaces:** - -- Consumes: from `@dynamic-field-kit/angular` (built fesm2022) — `DynamicInput` component, `FIELD_REGISTRY` injection token, `FieldRegistry`. Uses `@angular/core/testing` `TestBed` + `@angular/platform-browser-dynamic/testing` + `zone.js`. -- Produces: either an Angular smoke test, OR a documented decision to skip it (per the spec's best-effort clause). - -- [ ] **Step 1: Decide the timebox and add deps to attempt** - -Add to `smoke/package.json` devDependencies (then run `npm install`): - -```json - "@angular/common": "^19.0.0", - "@angular/compiler": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/platform-browser": "^19.0.0", - "@angular/platform-browser-dynamic": "^19.0.0", - "zone.js": "^0.15.0", -``` - -Add `"@dynamic-field-kit/angular": "*"` to `smoke/package.json` dependencies, and `'packages/angular/dist/fesm2022/dynamic-field-kit-angular.mjs'` (confirmed filename; angular has no `exports` map — its `module` field points here) to `requiredArtifacts` in `smoke/globalSetup.ts`. - -- [ ] **Step 2: Write the Angular smoke test `smoke/angular.smoke.test.ts`** - -```ts -import 'zone.js'; -import 'zone.js/testing'; -import { - DynamicInput, - FIELD_REGISTRY, - FieldRegistry, -} from '@dynamic-field-kit/angular'; -import { Component, Input } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting, -} from '@angular/platform-browser-dynamic/testing'; -import { By } from '@angular/platform-browser'; -import { beforeAll, describe, expect, it } from 'vitest'; - -declare module '@dynamic-field-kit/core' { - interface FieldTypeMap { - text: string; - } -} - -@Component({ - selector: 'dfk-smoke-text', - standalone: true, - template: '
{{ value }}
', -}) -class TextRenderer { - @Input() value?: unknown; -} - -beforeAll(() => { - TestBed.initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting() - ); -}); - -describe('angular built package renders', () => { - it('mounts a DynamicInput from the built dist and renders its value', () => { - const registry = new FieldRegistry(); - registry.register('text', TextRenderer as never); - - TestBed.configureTestingModule({ - imports: [DynamicInput], - providers: [{ provide: FIELD_REGISTRY, useValue: registry }], - }); - - const fixture = TestBed.createComponent(DynamicInput); - fixture.componentRef.setInput('type', 'text'); - fixture.componentRef.setInput('value', 'hi'); - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css('.smoke')); - expect(el.nativeElement.textContent).toBe('hi'); - }); -}); -``` - -- [ ] **Step 3: Build angular and run the smoke suite** - -Run: `npm run build --workspace=@dynamic-field-kit/angular && npm run test --workspace=@dynamic-field-kit/smoke` -Expected (success case): PASS — 3 test files, all passed. - -- [ ] **Step 4: Apply the drop criteria** - -If Step 3 fails because the built component needs the JIT compiler, or requires bootstrapping beyond the standard `initTestEnvironment` above, or is non-deterministic (zone/async flakiness): **revert this task entirely** — - -```bash -git checkout smoke/package.json smoke/globalSetup.ts -rm -f smoke/angular.smoke.test.ts -npm install -``` - -— and add this line to the smoke workspace by creating `smoke/README.md`: - -```markdown -# @dynamic-field-kit/smoke - -Render smoke tests that import each adapter's **built dist** as a real consumer -would and mount a component through the real framework runtime on jsdom. - -- React (`react.smoke.test.tsx`) and Vue (`vue.smoke.test.ts`) are covered here. -- Angular is intentionally **not** render-smoked: mounting its built fesm2022 - output via TestBed outside a real Angular app needs JIT/app bootstrapping that - is too fragile for a smoke test. Angular's built dist stays covered at the - import + registry-wiring level by `scripts/integration-cross-registry.js`. -``` - -- [ ] **Step 5: Commit (either outcome)** - -Success case: - -```bash -git add smoke/angular.smoke.test.ts smoke/package.json smoke/globalSetup.ts package-lock.json -git commit -m "test(smoke): render Angular DynamicInput from built dist" -``` - -Drop case: - -```bash -git add smoke/README.md package.json package-lock.json -git commit -m "docs(smoke): document why Angular is not render-smoked" -``` - ---- - -### Task 4: Wire the smoke into CI + final verification - -**Files:** - -- Modify: `.github/workflows/ci.yml` (the `verify` job) - -**Interfaces:** - -- Consumes: the `@dynamic-field-kit/smoke` workspace `test` script. -- Produces: a blocking CI step; no downstream consumers. - -- [ ] **Step 1: Add the smoke step to the `verify` job** - -In `.github/workflows/ci.yml`, in the `verify` job, immediately after the `Build packages` step, add: - -```yaml -- name: Smoke test built packages - run: npm run test --workspace=@dynamic-field-kit/smoke -``` - -(The `verify` job already runs `npm ci` and builds all four packages before this point, so the smoke's `dist` dependencies are present.) - -- [ ] **Step 2: Full local gate — build everything, then smoke** - -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 && npm run test --workspace=@dynamic-field-kit/smoke` -Expected: PASS — the smoke suite (2 tests, or 3 if Angular was kept). - -- [ ] **Step 3: Confirm nothing else regressed** - -Run: `npm run lint && npm run format-check` -Expected: both pass (the `smoke/` tests are not under the lint globs; format-check covers them — if it flags any smoke file, run `npx prettier --write smoke` and re-check). - -- [ ] **Step 4: Confirm the existing verify scripts still pass** - -Run: `node scripts/verify-framework-deps.js && node scripts/check-cross-framework-imports.js && node scripts/integration-cross-registry.js` -Expected: all exit 0. - -- [ ] **Step 5: Commit and push** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: run built-package render smoke in the verify job" -git push -u origin feat/published-package-smoke -``` - ---- - -## Notes for the implementer - -- **Root `npm test` behavior:** because `smoke` is now a workspace, `npm test` at the repo root (which fans out `--workspaces --if-present`) will include the smoke suite, which needs `dist` present. Without a prior build it fails with the friendly preflight message. This is expected; CI's per-package test matrix uses explicit `--workspace=` commands and is unaffected. Do not try to "fix" this by renaming the smoke `test` script — the CI verify step and `--workspace=@dynamic-field-kit/smoke` rely on it being `test`. -- **Why `globalSetup`, not `beforeAll`:** a static top-level `import` from a missing dist throws at module-load, before any `beforeAll` in the file runs. `globalSetup` runs before test modules load, so it can produce the readable error first. diff --git a/docs/superpowers/specs/2026-07-14-validation-conditions-design.md b/docs/superpowers/specs/2026-07-14-validation-conditions-design.md deleted file mode 100644 index 3e025ca..0000000 --- a/docs/superpowers/specs/2026-07-14-validation-conditions-design.md +++ /dev/null @@ -1,182 +0,0 @@ -# Validation & Dynamic Conditions — Design - -Date: 2026-07-14 -Status: Approved (design), pending implementation -Scope: Part 3, cycle 1 of 3 (the other cycles — new layout types, async `computeValue` — are separate specs). - -## Summary - -Add opt-in, app-supplied field **validation** and dynamic **disabled/readOnly** -conditions to dynamic-field-kit, without shipping any validation rule library or -form state into the framework-agnostic core. The core gains schema fields and -pure functions; each adapter wires them reactively into its `MultiFieldInput`. - -All changes are **additive and backward compatible**: forms that declare none of -the new fields behave exactly as today. - -## Philosophy constraints (from the README non-goals) - -- Core ships **no validation rule logic** (no `required`/`min`/`max`/`pattern` - helpers). Like `appearCondition` and `computeValue`, the app writes the rule - function; the engine only runs it. -- Core holds **no form state**. There is no engine-managed touched/submitted - tracking. Errors are surfaced reactively; _when to display_ them is the - renderer's / app's decision. -- **Sync only** in this cycle. Async validation is deferred to the async - `computeValue` cycle. - -## Schema additions (`FieldDescription`, in core) - -```ts -// Returns one or more error messages, or nothing when the value is valid. -validate?: ( - value: unknown, - data: Properties, - rootData?: Properties -) => string | string[] | undefined; - -// Dynamic disabled/readOnly, mirroring appearCondition's signature. -disabledCondition?: (data: Properties, rootData?: Properties) => boolean; -readOnlyCondition?: (data: Properties, rootData?: Properties) => boolean; -``` - -`data` is the field's own level (the group item when nested in a repeatable -group); `rootData` is the top-level form data — identical to the threading -already implemented for `appearCondition`/`computeValue` in Part 2. - -The static `disabled` flag added in Part 2 remains; the effective disabled state -is `disabled || disabledCondition?.(...)` (see `resolveDisabled`). - -## Renderer contract additions (`FieldRendererProps`, in core) - -```ts -error?: string | string[]; // current validation error(s) for this field -readOnly?: boolean; -// `disabled` already exists (added in Part 2) -``` - -Renderers may read `error` to show an inline message and `readOnly` to render a -non-editable control. Both are optional; existing renderers ignore them. - -## Core pure functions (new file `packages/core/src/validation.ts`) - -```ts -// Run one field's validate hook; always returns an array (empty when valid). -export function validateField( - field: FieldDescription, - value: unknown, - data: Properties, - rootData?: Properties -): string[]; - -// Recursively validate a field list against data, descending into repeatable -// groups. Returns a flat errors map keyed by field path and an overall flag. -// Group paths use `${name}[${index}].${childName}` (e.g. "contacts[0].email"). -export interface ValidationResult { - valid: boolean; - errors: Record; -} -export function validateFields( - fields: FieldDescription[], - data: Properties, - rootData?: Properties -): ValidationResult; - -// Effective disabled/readOnly for a field given the current data. -export function resolveDisabled( - field: FieldDescription, - data: Properties, - rootData?: Properties -): boolean; // field.disabled || disabledCondition?.(data, rootData) || false - -export function resolveReadOnly( - field: FieldDescription, - data: Properties, - rootData?: Properties -): boolean; // readOnlyCondition?.(data, rootData) || false -``` - -Behavioral rules: - -- `validateField` returns `[]` when there is no `validate` hook or it returns a - falsy value; a single string is wrapped into a one-element array. -- `validateFields` only descends into a field when `isFieldGroup(field)` is true; - each group item is validated against the group's nested `fields`, with the same - `rootData`. -- A field is **skipped** (not validated, contributes no errors, never makes the - form invalid) when it is hidden by `appearCondition` **or** disabled (static - `disabled` or `disabledCondition`, i.e. `resolveDisabled` is true). A `readOnly` - field is still validated — its value still counts. -- `rootData` defaults to `data` (top-level call), consistent with - `applyComputedValues`. - -These functions are exported from `@dynamic-field-kit/core` for apps that want to -validate the whole form on submit (including group items), independent of any -adapter. - -## Adapter wiring (React / Vue / Angular) - -For each **leaf** field it renders, every adapter's `MultiFieldInput`: - -1. Computes `error = validateField(field, data[field.name], data, rootData)` and - passes it to the renderer via `FieldRendererProps.error` — but only for fields - that are visible and enabled; a disabled field surfaces no error (same skip - policy as `validateFields`). This is reactive and **always surfaced** for - eligible fields — no touched/submit gating in the engine. -2. Computes effective `disabled = resolveDisabled(field, data, rootData)` and - `readOnly = resolveReadOnly(field, data, rootData)` and passes both down. -3. Emits `onValidityChange?({ valid, errors })` for the fields at its own level - whenever data changes. The payload is the same `ValidationResult` shape; it - covers this level's directly-rendered fields. - -Repeatable groups need no special aggregation wiring: each group item renders a -nested `MultiFieldInput`, which validates its own leaf fields and surfaces inline -errors at any depth automatically. Whole-tree validation (including group items) -is available to the app through the exported `validateFields` pure function. - -`onValidityChange` is a new optional output on `MultiFieldInput`: - -- React: `onValidityChange?: (result: ValidationResult) => void` prop. -- Vue: `onValidityChange` prop (same callback shape). -- Angular: `@Output() validityChange = new EventEmitter()`. - -Threading matches the existing `rootData` wiring: the effective root passes down -through `FieldGroupInput` / group rendering so nested validation sees the top-level -form. - -## Out of scope (this cycle) - -- No rule library (`required`, `min`, `max`, `pattern`, ...). If desired later, - it becomes a separate `@dynamic-field-kit/validation` package built on the - `validate` hook. -- No engine-managed touched/submitted/dirty state. -- No async `validate`. -- No cross-field error aggregation beyond `validateFields`; no error summary - component. - -## Testing - -- **Core** (`validation.test.ts`): `validateField` (no hook, string, array, - falsy); `validateFields` recursion into groups with path keys, skipping of - `appearCondition`-hidden **and** disabled fields, that `readOnly` fields are - still validated, `rootData` passthrough, `valid` flag; `resolveDisabled` - (static flag, condition, both) and `resolveReadOnly`. -- **Each adapter**: `error` reaches the renderer and updates when data changes; - effective `disabled`/`readOnly` reflect conditions; `onValidityChange` / - `validityChange` fires with the expected payload; hidden and disabled fields - don't produce errors. - -## Docs - -- Root `README.md` and all four package READMEs: a "Validation & conditions" - section covering the `validate` hook, `disabledCondition`/`readOnlyCondition`, - the new renderer props (`error`, `readOnly`), `onValidityChange`, the - `validateFields` submit-time helper, and an explicit note that display timing - is the app's responsibility (headless). - -## Backward compatibility - -Every addition is optional. A schema with no `validate`/`disabledCondition`/ -`readOnlyCondition` and a form with no `onValidityChange` handler behave exactly -as before this change. The new `FieldRendererProps` fields are optional and -ignored by existing renderers. diff --git a/docs/superpowers/specs/2026-07-16-angular-test-overhaul-design.md b/docs/superpowers/specs/2026-07-16-angular-test-overhaul-design.md deleted file mode 100644 index aa4eaa2..0000000 --- a/docs/superpowers/specs/2026-07-16-angular-test-overhaul-design.md +++ /dev/null @@ -1,205 +0,0 @@ -# Angular Test Overhaul — Design - -Date: 2026-07-16 -Status: approved, ready for planning - -## Problem - -The Angular package reports 36 passing tests. Essentially none of them test the -package. - -Verified on `develop` at `759ec9d`: - -- **Zero** specs use `TestBed`; no component is ever mounted. -- Eight assertions are tautologies (`expect(true).toBe(true)`, - `expect('row').toBe('row')`). -- Specs that appear to test behaviour re-implement the logic inline. For - example, `angular.spec.ts` filters `appearCondition` by hand inside the test - instead of calling the library, so it asserts against code written in the test - file. -- `DynamicInput.spec.ts` never references `DynamicInput`. - -This is not laziness in the specs — it is forced by the test infrastructure. - -### Root cause - -The karma setup cannot compile **any** import. A probe spec importing only -`@angular/core` fails identically to one importing `@dynamic-field-kit/core`: - -``` -Uncaught ReferenceError: exports is not defined - at test/__probe.spec.js:8:23 -``` - -`karma-typescript` emits CommonJS (`exports.x = ...`) and the bundler never -wraps it, so `exports` is undefined in the browser. The only specs that can pass -are ones that import nothing — which is exactly what the suite contains. - -Separately, `karma.conf.js` lists only `test/**/*.spec.ts` in `files`, so -`test.ts` — the sole caller of `getTestBed().initTestEnvironment(...)` — is -never loaded. TestBed was never initialised. - -This corrects an earlier diagnosis recorded during Part 3, which blamed the -CommonJS format of `@dynamic-field-kit/core` and dropped the Angular -`validation.spec.ts` on that basis. Core's format is not the problem; the -karma-typescript bundling is. - -### Consequence - -Angular's `DynamicInput` is the most intricate code in the repo (dynamic -`ViewContainerRef` rendering, manual prop sync, output subscription, fallback -HTML, recursive standalone imports) and has zero behavioural coverage. The -Part 2 and Part 3 Angular wiring is guarded only by build-time template -type-checking. - -## Goals - -- Real, mounted-component tests for all of `packages/angular/src`. -- One test toolchain across the repo. -- Angular stops being a special case in CI. - -## Non-goals - -- Changing Angular public API or renderer contract. -- Test work in core, react, or vue (tracked separately in the test backlog). -- E2E/example-app smoke tests. - -## Approach - -Replace karma/jasmine/karma-typescript with vitest + jsdom, matching the -versions react and vue already use (`vitest ^1.6.0`, `jsdom ^24`), and compile -Angular with `@analogjs/vite-plugin-angular@^1.16` (peer: -`@angular-devkit/build-angular@^19`). - -### Alternatives considered - -**SWC + `unplugin-swc`, converting constructor DI to `inject()`.** Lighter; no -`build-angular`. Rejected: it requires changing `src/` to suit the test runner, -and it is a lightly-documented path. - -**`jest-preset-angular`.** Most proven for Angular, but puts jest beside vitest -in one repo, defeating the toolchain-unification goal. - -Plain vitest with no Angular plugin was ruled out on evidence: -`BaseInput`, `FieldInput`, and `MultiFieldInput` use constructor DI -(`constructor(private cdr: ChangeDetectorRef)`), which needs -`design:paramtypes` metadata that esbuild does not emit. All four templates are -inline (no `templateUrl`), so nothing forces AOT. - -## Architecture - -| Item | From | To | -| -------- | ------------------------------------- | ---------------------------------------- | -| Runner | `karma start --single-run` | `vitest run` | -| Env | ChromeHeadless | jsdom | -| Compile | `karma-typescript` | `@analogjs/vite-plugin-angular` | -| Config | `karma.conf.js`, `tsconfig.spec.json` | `vitest.config.ts` | -| Setup | `test.ts` (never loaded) | `test/setup.ts`, loaded via `setupFiles` | -| Coverage | `karma-coverage` (reports `0/0`) | `@vitest/coverage-v8` | - -`test/setup.ts` imports `zone.js`, then `zone.js/testing`, then calls -`getTestBed().initTestEnvironment(BrowserDynamicTestingModule, -platformBrowserDynamicTesting())`. Load order matters and is the most likely -point of failure. - -Tests construct `new FieldRegistry()` (scoped registry, added in Part 2) rather -than the private-state reset hack `(fieldRegistry as any).registry = {}`. - -### Removed - -- Specs: `angular.spec.ts`, `DynamicInput.spec.ts`, `FieldInput.spec.ts`, - `integration.spec.ts`, `layouts.spec.ts`. -- Config: `karma.conf.js`, `tsconfig.spec.json`, `test.ts`. -- devDeps: `karma`, `karma-chrome-launcher`, `karma-coverage`, `karma-jasmine`, - `karma-typescript`, `jasmine`, `@types/jasmine`. -- Script: `test:coverage`. - -## Phases - -Each phase must be green before the next begins. - -**Phase 1 — infrastructure + spike.** Stand up the runner and mount one real -component. **Gate: if analog does not work here, stop and report before writing -further tests.** This phase also settles the `isComponentType` question below. - -**Phase 2 — high-risk core.** - -- `DynamicInput`: dynamic rendering via `ViewContainerRef`; prop sync across - `KNOWN_PROPS`; `valueChange` / `onValueChange` output binding; `extraProps` - forwarding; fallback HTML rendering; unknown-type error path; cleanup and - unsubscribe on destroy. -- `MultiFieldInput`: multi-field rendering; `appearCondition`; repeatable - groups; `computeValue`; and the Part 3 surface — `error`, effective - `disabled` / `readOnly`, and `validityChange`. - -**Phase 3 — remainder.** `FieldInput`, `BaseInput`, `layoutRegistry`, -`defaultLayouts`, `fieldRegistryToken`, `dynamic-field-kit.module`, -`public-api`. - -## Suspected bug: `isComponentType` - -`DynamicInput.ts:138-142`: - -```ts -private isComponentType(renderer: unknown): boolean { - return typeof renderer === 'object' && renderer !== null && 'cmp' in renderer; -} -``` - -The README instructs registering renderers as classes -(`fieldRegistry.register('text', TextFieldComponent as any)`). A class is -`typeof 'function'`, not `'object'`, and Angular's static is `ɵcmp`, not `cmp`. -So the predicate returns false for a real component; control reaches the -`typeof Renderer === 'function'` branch, calls `renderFallback`, invokes the -class without `new`, throws `TypeError`, and the `catch` renders the red -"Failed to render field" div. - -If that holds, the adapter's primary use case never worked, and no test could -have caught it. This is a hypothesis, not a finding: karma is broken, so it has -not been executed. Phase 1's first mounted test decides it. - -**Decision:** if confirmed, fix it in this cycle. The new tests would be red -from the start otherwise, and the test that exposes it serves as the red-green -evidence for the fix. The PR becomes "test overhaul + one bugfix". - -## CI - -In `.github/workflows/ci.yml`: - -- Drop `needs-chrome: true` from the angular matrix entry and drop the - `Install Chrome` (`browser-actions/setup-chrome`) step. jsdom needs no - browser, so angular stops being a special case. -- Change `test-cmd` to - `npm run test --workspace=@dynamic-field-kit/angular -- --coverage`, matching - react and vue. -- `coverage/lcov.info` gains real numbers; today angular reports - `Unknown% (0/0)` because karma-coverage had nothing to instrument. - -No coverage threshold is introduced here (test backlog item 5). - -## Verification - -Every phase green before the next. Final gate matches Part 3: build all four -packages, `npm run lint`, `npm run format-check`, all four suites, and the three -verify scripts (`verify-framework-deps.js`, `check-cross-framework-imports.js`, -`integration-cross-registry.js`). - -Angular's test count will drop well below 36. That is the point: 36 fake tests -are replaced by a smaller number of real ones. Count is not the success metric; -mounted-component coverage of `src/` is. - -## Risks - -- `@angular-devkit/build-angular` is a heavy install with tight peers (Angular - 19, TS ~5.6, vite 5 via vitest 1.6). Confined to devDependencies; nothing - published changes. -- `zone.js` / `zone.js/testing` load order before `initTestEnvironment` is the - usual breakage point for vitest + Angular. - -Both surface in Phase 1, which is why the gate exists. - -## Out of scope, noted - -The vue `test` script is bare `vitest` (watch mode) and hangs when run -non-interactively; react already uses `vitest run`. It is a one-word fix but -belongs to test backlog item 4, not this cycle. diff --git a/docs/superpowers/specs/2026-07-19-type-tests-and-ci-typecheck-design.md b/docs/superpowers/specs/2026-07-19-type-tests-and-ci-typecheck-design.md deleted file mode 100644 index 3a9747c..0000000 --- a/docs/superpowers/specs/2026-07-19-type-tests-and-ci-typecheck-design.md +++ /dev/null @@ -1,86 +0,0 @@ -# Type Tests + CI Typecheck Design - -**Date:** 2026-07-19 -**Branch:** `feat/type-tests-and-ci-typecheck` (based on `develop`) -**Backlog item:** #3 from `project_test_improvement_backlog` — "Make type tests real; wire a working typecheck into CI; fix the Windows `spawnSync('tsc')` bug." - -## Problem - -Two independent gaps let type regressions ship undetected: - -1. **Type tests are not real.** `packages/core/test/types.test.ts` constructs typed objects and then asserts the value it just assigned (`const f: FieldDescription = { name: 'x', type: 'text' }; expect(f.name).toBe('x')`). vitest compiles with esbuild, which **strips types without checking them**, so these assertions pass regardless of whether the type is correct. There is not a single negative assertion — nothing verifies that an invalid shape is _rejected_. A regression that widened or broke a public type would not fail any test. - -2. **There is no working repo-wide typecheck, and none in CI.** `npm run typecheck` shells out to `scripts/typecheck-all.js`, which calls `spawnSync('tsc', …)` with no shell. On Windows `tsc` resolves to `tsc.cmd`, which `spawnSync` cannot launch without a shell, so the script exits 1 without ever type-checking. It is also never invoked by CI. Separately, the per-package `tsc -p tsconfig.json` invocation omits `--noEmit`, and `packages/core/tsconfig.json` sets neither `noEmit` nor `outDir`, so running it **emits `.js`/`.d.ts` into the source tree**. - -The only real typecheck of `src` today is a side effect of each package's production build (tsup / ng-packagr) during CI's build matrix. - -## Goal - -- Type tests that fail the suite when a public type regresses, including negative (`@ts-expect-error`) cases. -- A `npm run typecheck` that works cross-platform (Windows included), emits nothing into source trees, and runs in CI. -- Remove the bespoke, broken `typecheck-all.js`. - -## Decisions (locked) - -- **Type-test tool: vitest typecheck** (`expectTypeOf` / `assertType` / `@ts-expect-error`), not `tsd`. No new dependency — `packages/core` already ships vitest `0.34.6`, which supports typecheck mode and auto-detects `*.test-d.ts`. Consistent with the rest of the repo. -- **Repo typecheck: npm workspaces**, not a bespoke script. Each type-checkable package gets its own `typecheck` script; the root fans out with `--workspaces --if-present`. `scripts/typecheck-all.js` is deleted, which removes the Windows `spawnSync` bug by construction rather than patching it. - -## Design - -### Part A — Real type tests (core only) - -Scope is `packages/core`; the type tests exercise core's exported public types, which every adapter depends on. - -**New file `packages/core/test/types.test-d.ts`** using `expectTypeOf` / `assertType` from vitest, plus `@ts-expect-error` for negative cases. Coverage: - -- `Properties` — is `Record`; accepts empty and mixed-value objects. -- `FieldRendererProps` — value narrows to `T`; all documented optional props are assignable; `onValueChange` signature. -- `FieldDescription` — minimal (`name` + `type`) accepted; optional fields accepted; `validate` / `disabledCondition` / `readOnlyCondition` signatures. -- `FieldTypeKey`, `FieldTypeMap` — augmentation via `declare module '../src'` still resolves. - -**Negative assertions the current suite lacks entirely** (these are the point of the change): - -- `FieldDescription` missing `type` → error. -- `FieldDescription.name` given a non-string → error. -- `FieldRendererProps` assigned a string `value` → error. -- `validate` returning a non-`string | undefined` → error. - -These run under `vitest --typecheck`, which reports a missing expected error (unused `@ts-expect-error`) or a failed `expectTypeOf` as a test failure. vitest 0.34's default typecheck include glob is `**/*.{test,spec}-d.ts`, so the file is picked up without extra config. - -**Script.** `packages/core/package.json` gains `"test:types"` running vitest in typecheck mode over the test dir. The exact flag form (`vitest --run --typecheck` vs the `typecheck` subcommand, and whether a `typecheck` block is needed in `vitest.config.js`) is verified against the installed 0.34.6 during implementation; the config-based `test.typecheck.enabled` form is the fallback if the CLI flag alone does not pick up the file. - -**Existing `types.test.ts`.** Keep only the assertions that exercise real _runtime_ behavior — the `appearCondition` / `validate` / `disabledCondition` / `readOnlyCondition` callbacks actually being invoked, and `Properties` special-character keys. Remove the "construct a typed object, assert the value just assigned" cases; their type intent moves to `types.test-d.ts` as real assertions. No real coverage is lost — the deleted lines asserted nothing about `src`. - -### Part B — Repo-wide typecheck via workspaces - -- `packages/core/package.json`: `typecheck` → `tsc -p tsconfig.json --noEmit` (add `--noEmit`; core's tsconfig has no `noEmit`/`outDir`, so without it tsc pollutes `src`). -- `packages/react/package.json`, `packages/vue/package.json`: add `typecheck` → `tsc -p tsconfig.json --noEmit`. -- `packages/angular`: **no** `typecheck` script. ng-packagr already type-checks Angular `src` at build time, and raw `tsc` cannot compile Angular decorators/templates without the Angular compiler. `--if-present` skips it. -- Root `package.json`: `typecheck` → `npm run typecheck --workspaces --if-present`. -- **Delete** `scripts/typecheck-all.js`. Confirm no other file references it (only the root script pointed at it today; CI does not). - -**Risk:** `react` / `vue` have never been type-checked in isolation, so latent errors may surface on first run. That is the tool doing its job; fix the errors, or if any is non-trivial, stop and report rather than loosening a type. `tsc -p` on react/vue will also compile their test files (whatever `include` covers) — if vitest/jsdom ambient types are needed, add them to the package `tsconfig` `types`/`include` as the minimal fix. - -### Part C — CI wiring - -`.github/workflows/ci.yml`: - -- Build `@dynamic-field-kit/core` first — react/vue resolve `@dynamic-field-kit/core` types through the workspace symlink to core's built `dist`, so typecheck needs `dist/*.d.ts` present. -- Run `npm run typecheck`. -- Run core's type tests: `npm run test:types --workspace=@dynamic-field-kit/core`. - -Placement (fold into the existing lint/format job vs a dedicated `typecheck` job) is decided during implementation against the current workflow structure; either way it is a required check, not `continue-on-error`. - -## Verification (evidence the tests are real) - -1. `npm run typecheck` exits 0 on Windows and leaves **no** new `.js`/`.d.ts` files in any `src` tree (`git status` clean after). -2. Temporarily break a `test-d.ts` assertion (e.g. delete an expected error) → `npm run test:types` **fails**; revert. -3. Temporarily introduce a type error in `packages/react/src` → `npm run typecheck` **fails**; revert. -4. `core` coverage floor (75/75/60/75) still met after trimming `types.test.ts`. -5. Full existing gates still green: `npm run lint`, `npm run format-check`, all four builds, core/react/vue/angular suites, and the three verify scripts. - -## Out of scope - -- Coverage thresholds / `fail_ci_if_error` (backlog item 5). -- Type tests for react/vue/angular adapters — item 3 targets core's public types; adapter type tests can be a later item. -- Any change to the `FieldDescription` / `FieldRendererProps` types themselves; this work only observes them. diff --git a/docs/superpowers/specs/2026-07-22-published-package-render-smoke-design.md b/docs/superpowers/specs/2026-07-22-published-package-render-smoke-design.md deleted file mode 100644 index c2aa46d..0000000 --- a/docs/superpowers/specs/2026-07-22-published-package-render-smoke-design.md +++ /dev/null @@ -1,67 +0,0 @@ -# Published-Package Render Smoke Test Design - -**Date:** 2026-07-22 -**Branch:** `feat/published-package-smoke` (based on `develop`) -**Backlog origin:** the trailing "also worth considering" note in `project_test_improvement_backlog` — "no example-app/e2e smoke test that the published packages render in a real app." - -## Problem - -Every existing test exercises **source**, not the **built artifact** a consumer installs: - -- Each package's vitest suite imports from `../src` (compiled on the fly by the vite/analog plugins). A packaging break — a wrong `exports` map, a missing file in `dist`, a bad tsup/ng-packagr emit — would not fail any of those suites. -- `scripts/integration-cross-registry.js` **does** load the built `dist` of all four packages, but only at the module level: it `require()`/`import()`s the dist and invokes the adapter registry wrappers as plain functions, asserting cross-registry wiring. It never mounts a component through the real framework runtime, so it cannot catch "the built component fails to render." -- Three example apps (`example/react-app` Next.js, `example/vue-app` Vite, `example/angular-app` Angular CLI) consume the packages via `file:` links, but they are **not wired into CI**, so nothing runs them automatically. - -The gap is specifically **rendering fidelity of the built package**: nothing asserts that a component imported from the published `dist` actually renders to DOM through React/Vue/Angular. - -## Goal - -- A CI-blocking smoke test that imports each adapter as a real consumer would (bare specifier → `dist` via the package `exports` map), mounts a component through the real framework runtime on jsdom, and asserts DOM output. -- Complement, not duplicate, `integration-cross-registry.js` (module wiring) — this adds the render step. -- Keep it fast and browser-free (jsdom), separate from the per-package source suites and their coverage floors. - -## Decisions (locked) - -- **Fidelity: jsdom render-smoke against `dist`**, not full browser e2e on the example apps and not an example-app build-only check. Highest value per cost: it catches "built package doesn't render" without a browser or three bundler builds. -- **Consumer-path resolution:** tests import the **bare specifier** (`@dynamic-field-kit/react`, `…/vue`, `…/core`). All three packages' `main`/`module`/`exports` point exclusively to `dist`, so a workspace importing the bare specifier resolves to the built artifact — the real-consumer path. (Verified 2026-07-22.) -- **Placement: a dedicated private `smoke/` workspace**, not plain node scripts (keeps RTL / `@vue/test-utils` ergonomics) and not co-located in each package's `test/` dir (would pollute the source suites and the coverage numbers just floored in item 5). -- **Framework scope: React + Vue now; Angular if feasible.** React/Vue render against built `dist` cleanly. Angular from `dist` via `TestBed` (built fesm2022 is AOT-compiled with `ɵcmp`, needs zone.js/platform bootstrap) is higher-risk; attempt it, include only if it runs cleanly, otherwise document why and leave Angular at its existing import-wiring level. -- **CI: blocking, in the existing `verify` job**, which already builds all packages then runs the integration scripts. - -## Design - -### The `smoke/` workspace - -- **`smoke/package.json`** — private `@dynamic-field-kit/smoke`. - - `dependencies`: `@dynamic-field-kit/core`, `@dynamic-field-kit/react`, `@dynamic-field-kit/vue` (workspace links; resolve to their `dist`). - - `devDependencies`: `vitest`, `@testing-library/react`, `@vue/test-utils`, `jsdom`, `@vitejs/plugin-react`, `react`, `react-dom`, `vue`. - - `scripts.test`: `vitest run`. -- **`smoke/vitest.config.ts`** — `plugins: [react()]`, `test: { environment: 'jsdom', globals: true }`. **No `coverage` block** — this suite asserts artifact behavior, not line coverage. -- **`smoke/react.smoke.test.tsx`** — `import { DynamicInput, FieldRegistryProvider, FieldRegistry } from '@dynamic-field-kit/react'`; make a fresh `FieldRegistry`, register a trivial renderer, render `` wrapped in `FieldRegistryProvider` (reusing the DI landed in item 6), assert the DOM text. -- **`smoke/vue.smoke.test.ts`** — same shape via `@vue/test-utils` `mount` + `global.provide` DI with `FieldRegistryKey` from `@dynamic-field-kit/vue`. -- **Dist preflight** — a small `beforeAll` (or a shared helper) that checks each imported package's `dist/index.*` exists and fails with a clear "run `npm run build` first" message, so a forgotten build produces a readable error rather than a raw module-resolution stack. - -### Angular (best-effort) - -- Attempt **`smoke/angular.smoke.test.ts`**: import the built `@dynamic-field-kit/angular`, bootstrap zone.js + the dynamic testing platform, register a renderer via the `FIELD_REGISTRY` token, create `DynamicInput` with `TestBed`, assert DOM. -- If it runs cleanly and deterministically on jsdom, include it. If it needs the JIT compiler or other real-app bootstrapping that makes it fragile, drop the Angular smoke, add a short comment in the workspace README/spec explaining that Angular stays at the `integration-cross-registry.js` import-wiring level, and move on. - -### CI - -- Add one step to the `verify` job after the existing "Build packages" step and alongside the "Run verification scripts" step (order relative to the three scripts does not matter — all are post-build artifact checks): - `npm run test --workspace=@dynamic-field-kit/smoke` -- Blocking. The `verify` job already runs on `ubuntu-latest`, builds all four packages, and runs the three verification scripts; the smoke fits the same "post-build, real-artifact" phase. - -## Testing - -The smoke _is_ the test. Verification of the work itself: - -- Build packages, run `npm run test --workspace=@dynamic-field-kit/smoke` → green. -- Prove it bites: temporarily break a package's `exports` (or delete a `dist` file) and confirm the smoke fails with the preflight/render error, then revert. -- Full local gate unaffected: `npm run lint && npm run format-check`, per-package suites, and the three verify scripts still pass. - -## Out of scope - -- The example apps' committed build caches (`example/angular-app/.angular/cache`, `build.log`, `tsconfig.tsbuildinfo`) — unrelated hygiene. -- Full browser e2e / driving the example apps. -- Angular is best-effort only, per the decision above. diff --git a/example/angular-app/.gitignore b/example/angular-app/.gitignore index 99e4aa3..d5aef9a 100644 --- a/example/angular-app/.gitignore +++ b/example/angular-app/.gitignore @@ -1,17 +1,19 @@ -node_modules/ -dist/ -out-tsc/ -.angular/ -*.local -*.log +node_modules/ +dist/ +out-tsc/ +.angular/ +*.local +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Dependencies +package-lock.json +yarn.lock -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Dependencies -package-lock.json -yarn.lock +demo-sources.ts diff --git a/example/angular-app/package.json b/example/angular-app/package.json index 962e6b7..2760270 100644 --- a/example/angular-app/package.json +++ b/example/angular-app/package.json @@ -4,7 +4,9 @@ "version": "0.0.0", "scripts": { "start": "ng serve --open", - "build": "ng build" + "build": "ng build", + "prestart": "node scripts/embed-demo-sources.js", + "prebuild": "node scripts/embed-demo-sources.js" }, "dependencies": { "@angular/common": "^19.0.0", diff --git a/example/angular-app/scripts/embed-demo-sources.js b/example/angular-app/scripts/embed-demo-sources.js new file mode 100644 index 0000000..c06c6ca --- /dev/null +++ b/example/angular-app/scripts/embed-demo-sources.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Angular's builder has no `?raw` import (Vite gives Vue one, and the Next demo + * reads its source in a Server Component). So the sources are embedded into a + * generated module instead. + * + * Wired to `prestart` and `prebuild`, so the panel can never show stale code: + * whatever is on disk at build time is what gets shipped. + */ + +const fs = require('fs'); +const path = require('path'); + +const APP_DIR = path.resolve(__dirname, '..', 'src', 'app'); +const DEMOS = ['enterprise', 'wizard']; +const OUT = path.join(APP_DIR, 'demo-sources.ts'); + +const entries = DEMOS.map((name) => { + const file = path.join(APP_DIR, 'demos', `${name}.component.ts`); + const source = fs.readFileSync(file, 'utf8'); + return ` ${name}: ${JSON.stringify(source)},`; +}).join('\n'); + +const output = `// GENERATED by scripts/embed-demo-sources.js - do not edit by hand. +// Regenerated on every \`npm start\` and \`npm run build\`. +export const DEMO_SOURCES: Record = { +${entries} +}; +`; + +fs.writeFileSync(OUT, output, 'utf8'); +process.stdout.write( + `embed-demo-sources: wrote ${path.relative(process.cwd(), OUT)} (${DEMOS.join( + ', ' + )})\n` +); diff --git a/example/angular-app/src/app/app.component.html b/example/angular-app/src/app/app.component.html index 3412a88..e8f2cc3 100644 --- a/example/angular-app/src/app/app.component.html +++ b/example/angular-app/src/app/app.component.html @@ -43,8 +43,136 @@ > ✨ Demo Tính Năng Mới (v1.3+) + + + + ← Tất cả demo + + +
+
+

+ {{ + activeTab === 'enterprise' + ? 'Tính năng Enterprise (v1.4+)' + : 'Multi-Step Wizard' + }} +

+ +
+ +
+
+ + +
+ +
+
+ src/app/demos/{{ activeTab }}.component.ts +
+
{{ currentSource() }}
+
+
+
+

Dynamic Field Kit — Angular Demo

diff --git a/example/angular-app/src/app/app.component.ts b/example/angular-app/src/app/app.component.ts index 928ffcf..3797e17 100644 --- a/example/angular-app/src/app/app.component.ts +++ b/example/angular-app/src/app/app.component.ts @@ -7,16 +7,33 @@ import { validateFields, validateFieldsAsync, } from '@dynamic-field-kit/core'; +import { DEMO_SOURCES } from './demo-sources'; +import { EnterpriseDemoComponent } from './demos/enterprise.component'; +import { WizardDemoComponent } from './demos/wizard.component'; import './fieldRegistry'; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule, MultiFieldInput], + imports: [ + CommonModule, + MultiFieldInput, + EnterpriseDemoComponent, + WizardDemoComponent, + ], templateUrl: './app.component.html', }) export class AppComponent { - activeTab: 'legacy' | 'new' = 'legacy'; + activeTab: 'legacy' | 'new' | 'enterprise' | 'wizard' = 'legacy'; + showCode = false; + + // The landing page only exists on the deployed site, one level above this + // app's base path, so link to it absolutely. + readonly ALL_DEMOS_URL = 'https://vannt-dev.github.io/dynamic-field-kit/'; + + currentSource(): string { + return DEMO_SOURCES[this.activeTab] ?? ''; + } // 1. Legacy fields legacyFields: FieldDescription[] = [ diff --git a/example/angular-app/src/app/demos/enterprise.component.ts b/example/angular-app/src/app/demos/enterprise.component.ts new file mode 100644 index 0000000..de34756 --- /dev/null +++ b/example/angular-app/src/app/demos/enterprise.component.ts @@ -0,0 +1,123 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + createDynamicFormStore, + DynamicFormDevToolsComponent, + MultiFieldInput, +} from '@dynamic-field-kit/angular'; +import { FieldDescription, validators } from '@dynamic-field-kit/core'; +import '../fieldRegistry'; + +const fields: FieldDescription[] = [ + { + name: 'country', + type: 'select', + label: '1. Quốc gia (Dynamic Options)', + options: [ + { label: 'Việt Nam', value: 'VN' }, + { label: 'Hoa Kỳ (USA)', value: 'US' }, + ], + validate: validators.required('Vui lòng chọn quốc gia'), + }, + { + name: 'gender', + type: 'radio', + label: '2. Giới tính (Extended HTML5 Radio)', + options: [ + { label: 'Nam', value: 'male' }, + { label: 'Nữ', value: 'female' }, + ], + }, + { + name: 'satisfaction', + type: 'range', + label: '3. Mức độ hài lòng (Range Slider)', + min: 1, + max: 10, + step: 1, + }, + { + name: 'email', + type: 'email', + label: '4. Email (Built-in Validators)', + 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: 'birthDate', type: 'date', label: '5. Ngày sinh' }, + { name: 'subscribeNewsletter', type: 'switch', label: '6. Nhận bản tin' }, +]; + +@Component({ + selector: 'app-enterprise-demo', + standalone: true, + imports: [CommonModule, MultiFieldInput, DynamicFormDevToolsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + +
+ + +
+ +
+

Form State (signals)

+

+ isDirty: {{ store.isDirty() }} · isValid: {{ store.isValid() }} · + isSubmitted: {{ store.isSubmitted() }} +

+
{{ store.data() | json }}
+
+ + +
+ `, +}) +export class EnterpriseDemoComponent { + fields = fields; + + // Signal-based store: the Angular counterpart of useDynamicForm. + store = createDynamicFormStore({ + fields, + initialValues: { + country: 'VN', + satisfaction: 8, + subscribeNewsletter: true, + }, + validateOnBlur: true, + }); + + onSubmit = this.store.handleSubmit((data) => { + alert(`Submit thành công:\n${JSON.stringify(data, null, 2)}`); + }); +} diff --git a/example/angular-app/src/app/demos/wizard.component.ts b/example/angular-app/src/app/demos/wizard.component.ts new file mode 100644 index 0000000..8b5ea52 --- /dev/null +++ b/example/angular-app/src/app/demos/wizard.component.ts @@ -0,0 +1,204 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MultiFieldInput } from '@dynamic-field-kit/angular'; +import { + canGoPrev, + createWizardState, + FieldDescription, + FormStep, + goNext, + goPrev, + isStepCompleted, + validateStep, + validators, + WizardState, +} from '@dynamic-field-kit/core'; +import '../fieldRegistry'; + +const steps: FormStep[] = [ + { + id: 'account', + title: 'Tài khoản', + fields: [ + { + name: 'email', + type: 'email', + label: 'Email', + 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ự') + ), + }, + ] as FieldDescription[], + }, + { + id: 'profile', + title: 'Hồ sơ', + fields: [ + { + 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' }, + ] as FieldDescription[], + }, + { + id: 'preferences', + title: 'Tuỳ chọn', + fields: [ + { + 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' }, + ] as FieldDescription[], + }, +]; + +@Component({ + selector: 'app-wizard-demo', + standalone: true, + imports: [CommonModule, MultiFieldInput], + template: ` +
    +
  1. + {{ completed(i) ? '✓ ' : i + 1 + '. ' }}{{ step.title }} +
  2. +
+ +
+ 🎉 Hoàn tất! +
{{ data | json }}
+
+ + +

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

+ + + +
    +
  • + {{ key }}: {{ errors[key].join(', ') }} +
  • +
+ +
+ + + + + +
+
+ `, +}) +export class WizardDemoComponent { + wizard: WizardState = createWizardState(steps); + data: Record = {}; + errors: Record = {}; + submitted = false; + + completed(index: number): boolean { + return isStepCompleted(this.wizard, index); + } + + canPrev(): boolean { + return canGoPrev(this.wizard); + } + + errorKeys(): string[] { + return Object.keys(this.errors); + } + + // goNext does not validate - the wizard decides whether a step may be left. + next(): void { + const result = validateStep(this.wizard.currentStep, this.data); + this.errors = result.errors; + if (result.valid) { + this.wizard = goNext(this.wizard); + } + } + + prev(): void { + this.wizard = goPrev(this.wizard); + } + + finish(): void { + const result = validateStep(this.wizard.currentStep, this.data); + this.errors = result.errors; + if (result.valid) { + this.submitted = true; + } + } +} diff --git a/example/react-app/app/DemoNav.tsx b/example/react-app/app/DemoNav.tsx new file mode 100644 index 0000000..779a22e --- /dev/null +++ b/example/react-app/app/DemoNav.tsx @@ -0,0 +1,62 @@ +'use client'; + +import Link from 'next/link'; + +/** + * Absolute rather than relative: the landing page only exists on the deployed + * Pages site, one level above each app's base path. A relative `../` would + * resolve to nothing when running the demo locally. + */ +export const ALL_DEMOS_URL = 'https://vannt-dev.github.io/dynamic-field-kit/'; + +const linkStyle: React.CSSProperties = { + marginRight: '16px', + fontWeight: 'normal', + color: '#0066cc', + textDecoration: 'none', +}; + +const currentStyle: React.CSSProperties = { + marginRight: '16px', + color: '#111', +}; + +type Page = 'basics' | 'new-features' | 'wizard'; + +const PAGES: { id: Page; href: string; label: string }[] = [ + { id: 'basics', href: '/', label: '📌 Cơ bản' }, + { id: 'new-features', href: '/new-features', label: '✨ Enterprise (v1.4+)' }, + { id: 'wizard', href: '/wizard', label: '🧭 Wizard nhiều bước' }, +]; + +export default function DemoNav({ current }: { current: Page }) { + return ( + + ); +} diff --git a/example/react-app/app/DemoShell.tsx b/example/react-app/app/DemoShell.tsx new file mode 100644 index 0000000..2c7e1df --- /dev/null +++ b/example/react-app/app/DemoShell.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState } from 'react'; +import DemoNav from './DemoNav'; + +type Page = 'basics' | 'new-features' | 'wizard'; + +interface Props { + current: Page; + title: string; + intro: React.ReactNode; + /** The demo's own source, read at build time. */ + code: string; + /** Shown as the panel's filename label. */ + codePath: string; + children: React.ReactNode; +} + +export default function DemoShell({ + current, + title, + intro, + code, + codePath, + children, +}: Props) { + const [showCode, setShowCode] = useState(false); + const [copied, setCopied] = useState(false); + + async function copy() { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( +
+ + +
+
+

{title}

+

+ {intro} +

+
+ +
+ +
+
{children}
+ + {showCode && ( +
+
+ + {codePath} + + +
+
+              {code}
+            
+
+ )} +
+
+ ); +} diff --git a/example/react-app/app/demo.tsx b/example/react-app/app/demo.tsx new file mode 100644 index 0000000..2c51e5b --- /dev/null +++ b/example/react-app/app/demo.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { FieldDescription } from '@dynamic-field-kit/core'; +import { MultiFieldInput } from '@dynamic-field-kit/react'; +import { useState } from 'react'; +import '../lib/fieldRegistry'; + +const fields: FieldDescription[] = [ + { name: 'firstName', type: 'text', label: 'First Name' }, + { name: 'lastName', type: 'text', label: 'Last Name' }, + { + name: 'fullName', + type: 'text', + label: 'Full Name (computed)', + // Derived from the two fields above whenever either one changes. + computeValue: (data) => + `${data.firstName ?? ''} ${data.lastName ?? ''}`.trim(), + }, + { name: 'age', type: 'number', label: 'Age' }, + { + name: 'contacts', + type: 'group', + label: 'Contacts', + // Repeatable field group: data.contacts becomes an array of items shaped + // by these sub-fields, with Add/Remove controls rendered automatically. + fields: [ + { name: 'email', type: 'text', label: 'Email' }, + { name: 'phone', type: 'text', label: 'Phone' }, + ], + defaultItem: { email: '', phone: '' }, + minItems: 0, + maxItems: 5, + }, +]; + +export default function BasicsDemo() { + const [data, setData] = useState({}); + + return ( + <> + +
{JSON.stringify(data, null, 2)}
+ + ); +} diff --git a/example/react-app/app/lib/readDemoSource.ts b/example/react-app/app/lib/readDemoSource.ts new file mode 100644 index 0000000..ae3cb16 --- /dev/null +++ b/example/react-app/app/lib/readDemoSource.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Reads a demo's own source so the page can show it next to the running form. + * + * Called from a Server Component, and every route here is statically exported, + * so this runs at build time and the text is baked into the page - no runtime + * fetch, and the snippet on screen is by construction the code that rendered + * the demo above it. + * + * @param file path relative to `app/`, e.g. `wizard/demo.tsx` + */ +export function readDemoSource(file: string): string { + return readFileSync(join(process.cwd(), 'app', file), 'utf8'); +} diff --git a/example/react-app/app/new-features/demo.tsx b/example/react-app/app/new-features/demo.tsx new file mode 100644 index 0000000..14646cd --- /dev/null +++ b/example/react-app/app/new-features/demo.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { FieldDescription, validators } from '@dynamic-field-kit/core'; +import { + MultiFieldInput, + useDynamicForm, + DynamicFormDevTools, +} from '@dynamic-field-kit/react'; +import '../../lib/fieldRegistry'; + +const fields: FieldDescription[] = [ + { + name: 'country', + type: 'select', + label: '1. Quốc gia (Dynamic Options)', + options: [ + { label: 'Việt Nam', value: 'VN' }, + { label: 'Hoa Kỳ (USA)', value: 'US' }, + ], + validate: validators.required('Vui lòng chọn quốc gia'), + }, + { + 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: 'email', + label: '4. Email (Built-in Validators)', + 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: 'birthDate', + type: 'date', + label: '5. Ngày sinh (Native Date Picker)', + }, + { + name: 'subscribeNewsletter', + type: 'switch', + label: '6. Nhận bản tin ưu đãi (Switch Toggle)', + }, +]; + +export default function NewFeaturesDemo() { + const form = useDynamicForm({ + fields, + initialValues: { + country: 'VN', + satisfaction: 8, + subscribeNewsletter: true, + }, + validateOnBlur: true, + }); + + return ( + <> +
+ alert(`Submit thành công:\n${JSON.stringify(validData, null, 2)}`) + )} + > + + +
+ + + +
+ + +
+

+ Form State (useDynamicForm): +

+
+          {JSON.stringify(
+            {
+              data: form.data,
+              isDirty: form.isDirty,
+              isValid: form.isValid,
+              errors: form.errors,
+            },
+            null,
+            2
+          )}
+        
+
+ + {/* Floating DevTools */} + + + ); +} diff --git a/example/react-app/app/new-features/page.tsx b/example/react-app/app/new-features/page.tsx index cb964aa..52fb51e 100644 --- a/example/react-app/app/new-features/page.tsx +++ b/example/react-app/app/new-features/page.tsx @@ -1,221 +1,24 @@ -'use client'; - -import { FieldDescription, validators } from '@dynamic-field-kit/core'; -import { - MultiFieldInput, - useDynamicForm, - DynamicFormDevTools, -} from '@dynamic-field-kit/react'; -import Link from 'next/link'; -import '../../lib/fieldRegistry'; - -const fields: FieldDescription[] = [ - { - name: 'country', - type: 'select', - label: '1. Quốc gia (Dynamic Options)', - options: [ - { label: 'Việt Nam', value: 'VN' }, - { label: 'Hoa Kỳ (USA)', value: 'US' }, - ], - validate: validators.required('Vui lòng chọn quốc gia'), - }, - { - 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: 'email', - label: '4. Email (Built-in Validators)', - 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: 'birthDate', - type: 'date', - label: '5. Ngày sinh (Native Date Picker)', - }, - { - name: 'subscribeNewsletter', - type: 'switch', - label: '6. Nhận bản tin ưu đãi (Switch Toggle)', - }, -]; +import DemoShell from '../DemoShell'; +import { readDemoSource } from '../lib/readDemoSource'; +import NewFeaturesDemo from './demo'; export default function NewFeaturesPage() { - const form = useDynamicForm({ - fields, - initialValues: { - country: 'VN', - satisfaction: 8, - subscribeNewsletter: true, - }, - validateOnBlur: true, - }); - return ( -
+ Minh hoạ useDynamicForm, Extended Renderers ( + radio, range, date,{' '} + switch), blur wiring qua onBlurField, và{' '} + DynamicFormDevTools ở góc màn hình. + + } > - - -

- Tính Năng Nâng Cấp Enterprise-Grade (v1.4+) -

-

- Minh họa: useDynamicForm state management hook, Extended - Renderers (radio, range, date,{' '} - switch), và Realtime DynamicFormDevTools ở góc - màn hình. -

- -
- alert(`Submit thành công:\n${JSON.stringify(validData, null, 2)}`) - )} - > - - -
- - - -
- - -
-

- Form State (useDynamicForm): -

-
-          {JSON.stringify(
-            {
-              data: form.data,
-              isDirty: form.isDirty,
-              isValid: form.isValid,
-              errors: form.errors,
-            },
-            null,
-            2
-          )}
-        
-
- - {/* Floating DevTools */} - -
+ + ); } diff --git a/example/react-app/app/page.tsx b/example/react-app/app/page.tsx index 2b5ac6a..99a4f19 100644 --- a/example/react-app/app/page.tsx +++ b/example/react-app/app/page.tsx @@ -1,97 +1,24 @@ -'use client'; - -import { FieldDescription } 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 fields: FieldDescription[] = [ - { name: 'firstName', type: 'text', label: 'First Name' }, - { name: 'lastName', type: 'text', label: 'Last Name' }, - { - name: 'fullName', - type: 'text', - label: 'Full Name (computed)', - // Derived from the two fields above whenever either one changes. - computeValue: (data) => - `${data.firstName ?? ''} ${data.lastName ?? ''}`.trim(), - }, - { name: 'age', type: 'number', label: 'Age' }, - { - name: 'contacts', - type: 'group', - label: 'Contacts', - // Repeatable field group: data.contacts becomes an array of items shaped - // by these sub-fields, with Add/Remove controls rendered automatically. - fields: [ - { name: 'email', type: 'text', label: 'Email' }, - { name: 'phone', type: 'text', label: 'Phone' }, - ], - defaultItem: { email: '', phone: '' }, - minItems: 0, - maxItems: 5, - }, -]; - -export default function Page() { - const [data, setData] = useState({}); +import BasicsDemo from './demo'; +import DemoShell from './DemoShell'; +import { readDemoSource } from './lib/readDemoSource'; +export default function HomePage() { return ( -
+ Nền tảng: đăng ký renderer qua fieldRegistry,{' '} + MultiFieldInput, layout, trường điều kiện ( + appearCondition), trường dẫn xuất ( + computeValue) và nhóm lặp lại. + + } > - - -

Dynamic Field Kit React Demo

- -
{JSON.stringify(data, null, 2)}
-
+ + ); } diff --git a/example/react-app/app/wizard/demo.tsx b/example/react-app/app/wizard/demo.tsx new file mode 100644 index 0000000..b497349 --- /dev/null +++ b/example/react-app/app/wizard/demo.tsx @@ -0,0 +1,225 @@ +'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 { 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 WizardDemo() { + 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 ( + <> + {/* 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/app/wizard/page.tsx b/example/react-app/app/wizard/page.tsx index c62f3ae..ca14445 100644 --- a/example/react-app/app/wizard/page.tsx +++ b/example/react-app/app/wizard/page.tsx @@ -1,273 +1,26 @@ -'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 }, -]; +import DemoShell from '../DemoShell'; +import { readDemoSource } from '../lib/readDemoSource'; +import WizardDemo from './demo'; +// Server Component: reads the demo's source at build time so the code panel +// shows exactly what is running beside it. 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 ? ( - - ) : ( - - )} -
+ Minh hoạ createWizardState, validateStep,{' '} + goNext / goPrev và{' '} + completedSteps. State là bất biến — mỗi lần điều hướng + trả về một state mới. - )} -
+ } + > + + ); } diff --git a/example/vue-app/src/App.vue b/example/vue-app/src/App.vue index e5a3f86..48a7a69 100644 --- a/example/vue-app/src/App.vue +++ b/example/vue-app/src/App.vue @@ -8,7 +8,30 @@ import { import { ref } from 'vue'; import './lib/fieldRegistry'; -const activeTab = ref<'legacy' | 'new'>('legacy'); +import EnterpriseDemo from './demos/EnterpriseDemo.vue'; +import WizardDemo from './demos/WizardDemo.vue'; +// Vite resolves `?raw` natively, so the panel shows the file that is running. +import enterpriseSource from './demos/EnterpriseDemo.vue?raw'; +import wizardSource from './demos/WizardDemo.vue?raw'; + +type Tab = 'legacy' | 'new' | 'enterprise' | 'wizard'; + +const activeTab = ref('legacy'); +const showCode = ref(false); + +// The landing page only exists on the deployed site, one level above this +// app's base path, so link to it absolutely. +const ALL_DEMOS_URL = 'https://vannt-dev.github.io/dynamic-field-kit/'; + +const tabStyle = (tab: Tab) => ({ + padding: '8px 16px', + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + fontWeight: activeTab.value === tab ? 'bold' : 'normal', + backgroundColor: activeTab.value === tab ? '#fff' : 'transparent', + boxShadow: activeTab.value === tab ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', +}); // 1. Legacy fields const legacyFields: FieldDescription[] = [ @@ -199,8 +222,118 @@ const handleValidate = async () => { > ✨ Demo Tính Năng Mới (v1.3+) + + + + ← Tất cả demo + + +
+
+

+ {{ + activeTab === 'enterprise' + ? 'Tính năng Enterprise (v1.4+)' + : 'Multi-Step Wizard' + }} +

+ +
+ +
+
+ + +
+ +
+
+ src/demos/{{ + activeTab === 'enterprise' ? 'EnterpriseDemo' : 'WizardDemo' + }}.vue +
+
{{
+              activeTab === 'enterprise' ? enterpriseSource : wizardSource
+            }}
+
+
+
+

Dynamic Field Kit Vue Demo

diff --git a/example/vue-app/src/demos/EnterpriseDemo.vue b/example/vue-app/src/demos/EnterpriseDemo.vue new file mode 100644 index 0000000..a3548ab --- /dev/null +++ b/example/vue-app/src/demos/EnterpriseDemo.vue @@ -0,0 +1,141 @@ + + + diff --git a/example/vue-app/src/demos/WizardDemo.vue b/example/vue-app/src/demos/WizardDemo.vue new file mode 100644 index 0000000..a662862 --- /dev/null +++ b/example/vue-app/src/demos/WizardDemo.vue @@ -0,0 +1,207 @@ + + + diff --git a/packages/angular/README.md b/packages/angular/README.md index ee2a388..c47711a 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -4,7 +4,7 @@ Angular adapter for `@dynamic-field-kit/core`. This package provides Angular components and a convenience module that render field schemas defined with `@dynamic-field-kit/core`. -Demo app: https://github.com/vannt-dev/dynamic-field-kit-demo +Live demo: https://vannt-dev.github.io/dynamic-field-kit/angular/ ## Install @@ -30,6 +30,8 @@ npm install @dynamic-field-kit/core@^1.0.12 @dynamic-field-kit/angular@^1.2.3 - `FieldRegistry` (class, for scoped registries) - `FIELD_REGISTRY` (injection token) - `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` +- `createDynamicFormStore` (signal-based form state) +- `DynamicFormDevToolsComponent` ## Basic setup (Angular 19+) @@ -89,6 +91,89 @@ export class AppComponent { > ``` +## Form state (`createDynamicFormStore`) + +A signal-based store — the Angular counterpart of React and Vue's +`useDynamicForm`, with the same members. Read them as signals. + +```ts +import { + createDynamicFormStore, + MultiFieldInput, +} from '@dynamic-field-kit/angular'; + +@Component({ + standalone: true, + imports: [MultiFieldInput], + template: ` +
+ + +
+ `, +}) +export class MyForm { + fields = fields; + store = createDynamicFormStore({ + fields, + initialValues: { country: 'VN' }, + validateOnBlur: true, // default + }); + + // handleSubmit returns a handler, exactly like React and Vue. + onSubmit = this.store.handleSubmit((data) => this.save(data)); +} +``` + +| 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 — bind to `(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 an async handler; calls `preventDefault`, validates, then dispatches | + +`MultiFieldInput` emits `(onBlurField)` with the field's name, driven by a +`focusout` listener — so it works with any renderer, without the renderer +needing a blur output of its own. + +## Default renderers + +`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` · +`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch` + +Any type you have not registered falls back to one of these. `file` emits a +`File` (or `File[]` when `multiple` is set), `range` and `number` emit numbers, +`checkbox` / `switch` emit booleans; everything else emits strings. + +## DevTools + +```html + +``` + +Import `DynamicFormDevToolsComponent`. A floating overlay with data / errors / +meta / fields tabs; the collapsed button carries a red badge with the number of +fields in error. + ## Layouts `MultiFieldInput` supports `column`, `row`, `grid`, and `responsive` (mobile/desktop with a custom breakpoint), matching the React and Vue adapters: diff --git a/packages/core/README.md b/packages/core/README.md index fef0d93..9323706 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,7 @@ Core types and shared registries for `dynamic-field-kit`. `@dynamic-field-kit/core` is intentionally framework-agnostic. It does not import React, Vue, or Angular types in its public API. Applications define field schemas in `core`, then register framework-specific renderers through an adapter package such as `@dynamic-field-kit/react`, `@dynamic-field-kit/vue`, or `@dynamic-field-kit/angular`. -Demo app: https://github.com/vannt-dev/dynamic-field-kit-demo +Live demo: https://vannt-dev.github.io/dynamic-field-kit/ ## What this package provides @@ -15,7 +15,9 @@ Demo app: https://github.com/vannt-dev/dynamic-field-kit-demo - Layout config types (`LayoutConfig`, `BaseLayout`, `ResponsiveLayout`, `ColumnLayoutConfig`, `RowLayoutConfig`, `GridLayoutConfig`) - the single source of truth re-exported by every adapter - `applyComputedValues` to resolve `computeValue` fields against form data - `validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly` and the `ValidationResult` type for opt-in, app-supplied validation and dynamic disabled/readOnly conditions -- `isFieldGroup`, `createGroupItem`, `canAddGroupItem`, `canRemoveGroupItem` to work with repeatable field groups (`FieldDescription.fields`) +- `isFieldGroup`, `createGroupItem`, `canAddGroupItem`, `canRemoveGroupItem` to work with repeatable field groups (`FieldDescription.fields`), plus `moveGroupItem`, `swapGroupItems`, `insertGroupItem` and `focusFirstInvalidField` for driving a group's array yourself +- `zodValidator`, `yupValidator`, `valibotValidator` / `standardSchemaValidator` to validate with an existing schema library +- A multi-step wizard state machine: `createWizardState`, `validateStep`, `canGoNext` / `canGoPrev`, `goNext` / `goPrev` / `goToStep`, `markStepCompleted` / `isStepCompleted` ## Install @@ -216,6 +218,110 @@ when fields define async `validate` functions. Adapters call `validateField` / `resolveDisabled` / `resolveReadOnly` per field to surface `error`, `disabled`, and `readOnly` to renderers reactively. +### Schema adapters (Zod, Yup, Valibot / Standard Schema) + +Attach a schema 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, + yupValidator, + valibotValidator, +} from '@dynamic-field-kit/core'; + +const schema = z.object({ email: z.string().email() }); + +const fields: FieldDescription[] = [ + { 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' }); +``` + +Both accept `SchemaValidatorOptions` — `{ field?: string; target?: 'form' | 'field' }`. +`valibotValidator` is an alias of `standardSchemaValidator`, which handles any +Standard Schema object (including Zod's `~standard`). + +Adapters parse **synchronously**, so the result works with the synchronous +`validateFields` (and therefore with the framework form hooks). A schema with +async refinements or async `.test()` rules cannot be parsed synchronously — +those return a Promise, so validate through `validateFieldsAsync`. + +## Multi-step wizard + +A framework-agnostic state machine over grouped fields. State is immutable: +every navigation returns a new object. + +```ts +import { + createWizardState, + validateStep, + goNext, + goPrev, + isStepCompleted, +} from '@dynamic-field-kit/core'; + +let wizard = createWizardState([ + { id: 'account', title: 'Account', fields: accountFields }, + { id: 'profile', title: 'Profile', fields: profileFields }, +]); + +const { valid, errors } = validateStep(wizard.currentStep, data); +if (valid) { + 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, defaulting to the current one | +| `isStepCompleted(state, index)` | For rendering a step indicator | + +`WizardState` carries `currentStep`, `currentStepIndex`, `totalSteps`, +`isFirstStep`, `isLastStep`, `steps` and `completedSteps`. `goNext` does not +validate — call `validateStep` yourself so a "save draft and come back" flow +stays possible. + +## Group array helpers + +Every adapter's `MultiFieldInput` renders add/remove controls itself. These +helpers are for driving a group's array yourself — a drag handle, a "duplicate +row" button, a custom group renderer. All are pure and never mutate the input: + +```ts +import { + moveGroupItem, + swapGroupItems, + insertGroupItem, + isFieldGroup, + createGroupItem, + canAddGroupItem, + canRemoveGroupItem, + focusFirstInvalidField, +} from '@dynamic-field-kit/core'; + +const reordered = moveGroupItem(items, 3, 0); // same array back 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); +``` + ## Repeatable field groups A `FieldDescription` with `fields` becomes a repeatable group instead of a registry-rendered leaf field: `data[name]` becomes an array of items, each shaped by the nested `fields`. Every adapter's `MultiFieldInput` renders one nested form per item plus "Add"/"Remove" controls automatically - no adapter-specific wiring required. diff --git a/packages/react/README.md b/packages/react/README.md index 40867a9..a9d845d 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,7 +4,7 @@ React adapter for `@dynamic-field-kit/core`. This package provides React components for rendering `FieldDescription[]` and exports a React-typed `fieldRegistry`, so registered renderers can be used directly as JSX components. -Demo app: https://github.com/vannt-dev/dynamic-field-kit-demo +Live demo: https://vannt-dev.github.io/dynamic-field-kit/react/ ## Install @@ -32,6 +32,7 @@ Note: `@dynamic-field-kit/core`, `react`, and `react-dom` are **peer dependencie - `FieldRendererProps` - `LayoutConfig` - `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` +- `defaultRenderersMap` / `getDefaultRenderer` `FieldGroupInput` (repeatable field groups) is used internally by `FieldInput` and doesn't need to be imported directly - see "Repeatable field groups" below. @@ -98,6 +99,91 @@ export function Example() { } ``` +## Form state (`useDynamicForm`) + +Holds data, errors, touched and submission state for a set of fields. Vue's +composable and Angular's `createDynamicFormStore` expose the same surface. + +```tsx +import { useDynamicForm, MultiFieldInput } from '@dynamic-field-kit/react'; + +const form = useDynamicForm({ + fields, + initialValues: { country: 'VN' }, + validateOnBlur: true, // default + validateOnChange: false, // default +}); + +
save(data))}> + + +; +``` + +| 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 | +| `setData` | Raw state setter, for escape hatches | +| `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 | + +`MultiFieldInput` tracks touched internally regardless; `onBlurField` is the +hook for driving an external store like this one. + +## Default renderers + +`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` · +`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch` + +Any type you have not registered falls back to one of these. Reach the map +directly if you need to wrap or inspect a default: + +```ts +import { + defaultRenderersMap, + getDefaultRenderer, +} from '@dynamic-field-kit/react'; + +const Base = getDefaultRenderer('date'); // undefined for an unknown type +``` + +`file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number` +emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings. + +## DevTools + +```tsx +import { DynamicFormDevTools } from '@dynamic-field-kit/react'; + +; +``` + +A floating overlay with data / errors / meta / fields tabs. The collapsed +button carries a red badge with the number of fields in error. + ## Layouts Use a layout name: diff --git a/packages/vue/README.md b/packages/vue/README.md index 27d02c9..64e0f13 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -4,7 +4,7 @@ Vue 3 adapter for `@dynamic-field-kit/core`. This package provides Vue components that render `FieldDescription[]` and resolve field renderers through the shared registry used by `dynamic-field-kit`. -Demo app: https://github.com/vannt-dev/dynamic-field-kit-demo +Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ ## Install @@ -29,6 +29,9 @@ Note: `@dynamic-field-kit/core` and `vue` are **peer dependencies** — this ada - `Properties` - `LayoutConfig` - `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` +- `useDynamicForm` +- `DynamicFormDevTools` +- `defaultRenderersMap` / `getDefaultRenderer` Default layouts are registered automatically when you import the package root. @@ -101,6 +104,91 @@ function handleChange(data: Record) { ``` +## Form state (`useDynamicForm`) + +Holds data, errors, touched and submission state for a set of fields. Everything +is a `ref` (or `computed`), so read through `.value` in ` + + +``` + +| Member | Description | +| ----------------------------------- | --------------------------------------------------------------------------------- | +| `data` | `Ref` of the form data, with `computeValue` fields applied | +| `errors` | `Ref>`, keyed like `validateFields` | +| `isValid` / `isDirty` | `computed` / `Ref` | +| `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 | + +`onBlurField` is what connects `handleBlur` — and therefore `touched` and +`validateOnBlur` — to the rendered form. + +## Default renderers + +`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` · +`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch` + +Any type you have not registered falls back to one of these. + +```ts +import { + defaultRenderersMap, + getDefaultRenderer, +} from '@dynamic-field-kit/vue'; + +const Base = getDefaultRenderer('date'); // undefined for an unknown type +``` + +`file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number` +emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings. + +## DevTools + +```vue + +``` + +A floating overlay with data / errors / meta / fields tabs. The collapsed +button carries a red badge with the number of fields in error. + ## Layouts Use a layout name: