From 76dd75e6802b4efbd7a38b8bfa046a8de752ea95 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 4 Aug 2026 23:56:10 +0700 Subject: [PATCH 01/20] feat(core): add Zod and Yup schema validation helpers and extend default renderers map --- packages/core/src/types.ts | 6 +++ packages/core/src/validation.ts | 63 +++++++++++++++++++++++++++ packages/core/test/validation.test.ts | 39 ++++++++++++++++- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 28cc8c6..c532dd1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,12 @@ export interface FieldTypeMap { textarea: string; password: string; email: string; + radio: string | number; + range: number; + file: unknown; + date: string; + time: string; + 'datetime-local': string; } export type Properties = Record; diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index abd71bd..af0a50e 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -167,3 +167,66 @@ export async function validateFieldsAsync( return { valid: Object.keys(errors).length === 0, errors }; } + +/** + * Helper to adapt a Zod schema to a FieldDescription validate function. + */ +export function zodValidator( + schema: { + safeParse: (data: unknown) => { + success: boolean; + error?: { + errors: Array<{ message: string; path: Array }>; + }; + }; + }, + fieldName?: string +): (value: unknown, data: Properties) => string | string[] | undefined { + return (value: unknown, data: Properties) => { + const target = fieldName ? data || {} : value; + const result = schema.safeParse(target); + if (result.success || !result.error) { + return undefined; + } + const matchingErrors = fieldName + ? result.error.errors + .filter( + (err) => + err.path.join('.') === fieldName || err.path[0] === fieldName + ) + .map((err) => err.message) + : result.error.errors.map((err) => err.message); + + return matchingErrors.length > 0 ? matchingErrors : undefined; + }; +} + +/** + * Helper to adapt a Yup schema to a FieldDescription validate function. + */ +export function yupValidator( + schema: { validateSync: (value: unknown, opts?: unknown) => unknown }, + fieldName?: string +): (value: unknown, data: Properties) => string | string[] | undefined { + return (value: unknown, data: Properties) => { + try { + const target = fieldName ? data || {} : value; + schema.validateSync(target, { abortEarly: false }); + return undefined; + } catch (err: unknown) { + const yupErr = err as { + inner?: Array<{ path?: string; message: string }>; + message?: string; + }; + if (yupErr.inner && yupErr.inner.length > 0) { + const matching = fieldName + ? yupErr.inner + .filter((e) => e.path === fieldName) + .map((e) => e.message) + : yupErr.inner.map((e) => e.message); + return matching.length > 0 ? matching : undefined; + } + return yupErr.message ? [yupErr.message] : undefined; + } + }; +} diff --git a/packages/core/test/validation.test.ts b/packages/core/test/validation.test.ts index 1fe1044..21cf224 100644 --- a/packages/core/test/validation.test.ts +++ b/packages/core/test/validation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import type { FieldDescription } from '../src'; import { resolveDisabled, resolveOptions, @@ -6,8 +7,9 @@ import { validateField, validateFields, validateFieldsAsync, + zodValidator, + yupValidator, } from '../src/validation'; -import type { FieldDescription } from '../src'; declare module '../src' { interface FieldTypeMap { @@ -203,3 +205,38 @@ describe('validateFieldsAsync', () => { expect(resOk.valid).toBe(true); }); }); + +describe('zodValidator and yupValidator', () => { + test('zodValidator validates field target or data object', () => { + const mockZod = { + safeParse: (val: unknown) => { + if (typeof val === 'string' && val.includes('@')) { + return { success: true }; + } + return { + success: false, + error: { + errors: [{ message: 'Invalid email address', path: [] }], + }, + }; + }, + }; + const validator = zodValidator(mockZod); + expect(validator('invalid', {})).toEqual(['Invalid email address']); + expect(validator('user@test.com', {})).toBeUndefined(); + }); + + test('yupValidator validates field target or data object', () => { + const mockYup = { + validateSync: (val: unknown) => { + if (typeof val === 'string' && val.length >= 3) { + return val; + } + throw { message: 'Must be at least 3 chars' }; + }, + }; + const validator = yupValidator(mockYup); + expect(validator('hi', {})).toEqual(['Must be at least 3 chars']); + expect(validator('hello', {})).toBeUndefined(); + }); +}); From e781f7166be09ee1fd66c0be724fdb7f55dd050d Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:06:25 +0700 Subject: [PATCH 02/20] feat: add useDynamicForm, schema adapters, extended renderers, wizard engine, and DevTools --- .../src/components/DynamicFormDevTools.ts | 149 +++++++++ .../angular/src/lib/dynamic-form.store.ts | 110 ++++++ packages/angular/src/public-api.ts | 2 + .../angular/test/dynamicFormStore.spec.ts | 27 ++ packages/core/src/adapters.ts | 167 ++++++++++ packages/core/src/fieldGroup.ts | 70 ++++ packages/core/src/index.ts | 7 + packages/core/src/types.ts | 11 + packages/core/src/validation.ts | 63 ---- packages/core/src/wizard.ts | 50 +++ packages/core/test/adapters.test.ts | 72 ++++ packages/core/test/validation.test.ts | 3 +- packages/core/test/wizard.test.ts | 65 ++++ .../src/components/DynamicFormDevTools.tsx | 236 +++++++++++++ packages/react/src/defaultRenderers.tsx | 137 ++++++++ packages/react/src/index.ts | 3 + packages/react/src/useDynamicForm.ts | 139 ++++++++ packages/react/test/extendedFeatures.test.tsx | 99 ++++++ .../vue/src/components/DynamicFormDevTools.ts | 313 ++++++++++++++++++ packages/vue/src/defaultRenderers.ts | 206 ++++++++++++ packages/vue/src/index.ts | 2 + packages/vue/src/useDynamicForm.ts | 115 +++++++ packages/vue/test/extendedFeatures.test.ts | 64 ++++ 23 files changed, 2045 insertions(+), 65 deletions(-) create mode 100644 packages/angular/src/components/DynamicFormDevTools.ts create mode 100644 packages/angular/src/lib/dynamic-form.store.ts create mode 100644 packages/angular/test/dynamicFormStore.spec.ts create mode 100644 packages/core/src/adapters.ts create mode 100644 packages/core/src/wizard.ts create mode 100644 packages/core/test/adapters.test.ts create mode 100644 packages/core/test/wizard.test.ts create mode 100644 packages/react/src/components/DynamicFormDevTools.tsx create mode 100644 packages/react/src/useDynamicForm.ts create mode 100644 packages/react/test/extendedFeatures.test.tsx create mode 100644 packages/vue/src/components/DynamicFormDevTools.ts create mode 100644 packages/vue/src/useDynamicForm.ts create mode 100644 packages/vue/test/extendedFeatures.test.ts diff --git a/packages/angular/src/components/DynamicFormDevTools.ts b/packages/angular/src/components/DynamicFormDevTools.ts new file mode 100644 index 0000000..0b2a348 --- /dev/null +++ b/packages/angular/src/components/DynamicFormDevTools.ts @@ -0,0 +1,149 @@ +import { NgIf, NgFor, JsonPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + signal, +} from '@angular/core'; +import { FieldDescription, Properties } from '@dynamic-field-kit/core'; + +@Component({ + selector: 'dfk-dev-tools', + standalone: true, + imports: [NgIf, NgFor, JsonPipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + +
+
+ 🛠️ Form DevTools + +
+ +
+ +
+ +
+
+          {{ data | json }}
+        
+
+          {{ errors | json }}
+        
+
+
isDirty: {{ isDirty }}
+
{{
+            touched | json
+          }}
+
+
+
+
{{ f.name }}
+
+ type: {{ f.type }} +
+
+
+
+
+ `, +}) +export class DynamicFormDevToolsComponent { + @Input() data: Properties = {}; + @Input() errors: Record = {}; + @Input() touched: Record = {}; + @Input() isDirty = false; + @Input() fields: FieldDescription[] = []; + + isOpen = signal(false); + activeTab = signal<'data' | 'errors' | 'meta' | 'fields'>('data'); + tabs: Array<'data' | 'errors' | 'meta' | 'fields'> = [ + 'data', + 'errors', + 'meta', + 'fields', + ]; +} diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts new file mode 100644 index 0000000..7e1b7bc --- /dev/null +++ b/packages/angular/src/lib/dynamic-form.store.ts @@ -0,0 +1,110 @@ +import { computed, signal } from '@angular/core'; +import { + applyComputedValues, + FieldDescription, + Properties, + validateFields, +} from '@dynamic-field-kit/core'; + +export interface DynamicFormOptions { + fields: FieldDescription[]; + initialValues?: Properties; + validateOnBlur?: boolean; + validateOnChange?: boolean; +} + +export function createDynamicFormStore(options: DynamicFormOptions) { + const fields = options.fields; + const initialValues = options.initialValues || {}; + const validateOnBlur = options.validateOnBlur ?? true; + const validateOnChange = options.validateOnChange ?? false; + + const data = signal(applyComputedValues(fields, initialValues)); + const errors = signal>({}); + const isDirty = signal(false); + const touched = signal>({}); + const isSubmitting = signal(false); + const isSubmitted = signal(false); + + const isValid = computed(() => Object.keys(errors()).length === 0); + + function validate(): boolean { + const res = validateFields(fields, data()); + errors.set(res.errors); + return res.valid; + } + + function handleChange(newData: Properties) { + const next = applyComputedValues(fields, newData); + data.set(next); + isDirty.set(true); + + if (validateOnChange) { + const res = validateFields(fields, next); + errors.set(res.errors); + } + } + + function setFieldValue(name: string, value: unknown) { + handleChange({ ...data(), [name]: value }); + } + + function setFieldTouched(name: string, isTouched = true) { + touched.set({ ...touched(), [name]: isTouched }); + } + + function handleBlur(fieldName: string) { + setFieldTouched(fieldName, true); + if (validateOnBlur) { + const res = validateFields(fields, data()); + errors.set(res.errors); + } + } + + function reset(newValues?: Properties) { + const seed = newValues ?? initialValues; + const next = applyComputedValues(fields, seed); + data.set(next); + errors.set({}); + isDirty.set(false); + touched.set({}); + isSubmitting.set(false); + isSubmitted.set(false); + } + + async function handleSubmit( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) { + isSubmitting.set(true); + try { + const res = validateFields(fields, data()); + errors.set(res.errors); + isSubmitted.set(true); + if (res.valid) { + await onValid(data()); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + isSubmitting.set(false); + } + } + + return { + data, + errors, + isValid, + isDirty, + touched, + isSubmitting, + isSubmitted, + setFieldValue, + setFieldTouched, + handleChange, + handleBlur, + reset, + validate, + handleSubmit, + }; +} diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index b1ba52a..8882c3b 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -5,6 +5,8 @@ export * from './components/BaseInput'; export * from './components/DynamicInput'; export * from './components/FieldInput'; export * from './components/MultiFieldInput'; +export * from './components/DynamicFormDevTools'; +export * from './lib/dynamic-form.store'; // Layout export * from './layout'; diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts new file mode 100644 index 0000000..5fe4c6e --- /dev/null +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -0,0 +1,27 @@ +import { FieldDescription } from '@dynamic-field-kit/core'; +import { describe, expect, it } from 'vitest'; +import { createDynamicFormStore } from '../src/lib/dynamic-form.store'; + +describe('Angular Signal DynamicFormStore', () => { + it('initializes signal data and handles changes', () => { + const fields: FieldDescription[] = [ + { name: 'username', type: 'text', required: true }, + ]; + + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + + expect(store.data().username).toBe('john_doe'); + expect(store.isDirty()).toBe(false); + + store.setFieldValue('username', 'jane_doe'); + expect(store.data().username).toBe('jane_doe'); + expect(store.isDirty()).toBe(true); + + store.reset(); + expect(store.data().username).toBe('john_doe'); + expect(store.isDirty()).toBe(false); + }); +}); diff --git a/packages/core/src/adapters.ts b/packages/core/src/adapters.ts new file mode 100644 index 0000000..e7b76a4 --- /dev/null +++ b/packages/core/src/adapters.ts @@ -0,0 +1,167 @@ +import { Properties } from './types'; + +export type FieldValidatorResult = + | string + | string[] + | undefined + | Promise; + +export type FieldValidatorFunction = ( + value: unknown, + data: Properties, + rootData?: Properties +) => FieldValidatorResult; + +/** + * Validates whole form data or a specific field using a Zod schema. + */ +export function zodValidator( + schema: any, + fieldName?: string +): FieldValidatorFunction { + return (value: unknown, data: Properties) => { + const payload = fieldName + ? { ...data, [fieldName]: value } + : value !== undefined + ? value + : data; + + if (typeof schema.safeParseAsync === 'function') { + return schema.safeParseAsync(payload).then((res: any) => { + if (res.success) { + return undefined; + } + const issues = res.error?.issues || res.error?.errors || []; + const matched = fieldName + ? issues.filter( + (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) + ) + : issues; + if (matched.length === 0) { + return undefined; + } + return matched.map((i: any) => i.message); + }); + } + + if (typeof schema.safeParse === 'function') { + const res = schema.safeParse(payload); + if (res.success) { + return undefined; + } + const issues = res.error?.issues || res.error?.errors || []; + const matched = fieldName + ? issues.filter( + (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) + ) + : issues; + if (matched.length === 0) { + return undefined; + } + return matched.map((i: any) => i.message); + } + + return undefined; + }; +} + +/** + * Validates whole form data or a specific field using a Yup schema. + */ +export function yupValidator( + schema: any, + fieldName?: string +): FieldValidatorFunction { + return (value: unknown, data: Properties) => { + const payload = fieldName + ? { ...data, [fieldName]: value } + : value !== undefined + ? value + : data; + + try { + if (typeof schema.validateSync === 'function') { + schema.validateSync(payload, { abortEarly: false }); + return undefined; + } + } catch (err: any) { + if (err.inner && Array.isArray(err.inner)) { + const matched = fieldName + ? err.inner.filter((i: any) => i.path === fieldName) + : err.inner; + if (matched.length === 0) { + return undefined; + } + return matched.map((i: any) => i.message); + } + return err.message ? [err.message] : undefined; + } + + if (typeof schema.validate === 'function') { + return schema + .validate(payload, { abortEarly: false }) + .then(() => undefined) + .catch((err: any) => { + if (err.inner && Array.isArray(err.inner)) { + const matched = fieldName + ? err.inner.filter((i: any) => i.path === fieldName) + : err.inner; + if (matched.length === 0) { + return undefined; + } + return matched.map((i: any) => i.message); + } + return err.message ? [err.message] : undefined; + }); + } + + return undefined; + }; +} + +/** + * Validates whole form data or a specific field using a Valibot or Standard-Schema compatible object. + */ +export function standardSchemaValidator( + schema: any, + fieldName?: string +): FieldValidatorFunction { + return (value: unknown, data: Properties) => { + const payload = fieldName ? { ...data, [fieldName]: value } : data; + const std = schema['~standard'] || schema; + if (typeof std.validate === 'function') { + const result = std.validate(payload); + if (result instanceof Promise) { + return result.then((res: any) => { + if (!res.issues || res.issues.length === 0) { + return undefined; + } + const matched = fieldName + ? res.issues.filter( + (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) + ) + : res.issues; + if (matched.length === 0) { + return undefined; + } + return matched.map((i: any) => i.message); + }); + } + if (!result.issues || result.issues.length === 0) { + return undefined; + } + const matched = fieldName + ? result.issues.filter( + (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) + ) + : result.issues; + if (matched.length === 0) { + return undefined; + } + return matched.map((i: any) => i.message); + } + return undefined; + }; +} + +export const valibotValidator = standardSchemaValidator; diff --git a/packages/core/src/fieldGroup.ts b/packages/core/src/fieldGroup.ts index 421cd6d..07cde7a 100644 --- a/packages/core/src/fieldGroup.ts +++ b/packages/core/src/fieldGroup.ts @@ -23,3 +23,73 @@ export function canRemoveGroupItem( ): boolean { return field.minItems === undefined || items.length > field.minItems; } + +export function moveGroupItem( + items: Properties[], + fromIndex: number, + toIndex: number +): Properties[] { + if ( + fromIndex < 0 || + fromIndex >= items.length || + toIndex < 0 || + toIndex >= items.length + ) { + return items; + } + const result = [...items]; + const [removed] = result.splice(fromIndex, 1); + result.splice(toIndex, 0, removed); + return result; +} + +export function swapGroupItems( + items: Properties[], + indexA: number, + indexB: number +): Properties[] { + if ( + indexA < 0 || + indexA >= items.length || + indexB < 0 || + indexB >= items.length + ) { + return items; + } + const result = [...items]; + const temp = result[indexA]; + result[indexA] = result[indexB]; + result[indexB] = temp; + return result; +} + +export function insertGroupItem( + items: Properties[], + index: number, + newItem: Properties = {} +): Properties[] { + const safeIndex = Math.max(0, Math.min(index, items.length)); + const result = [...items]; + result.splice(safeIndex, 0, newItem); + return result; +} + +export function focusFirstInvalidField( + containerOrForm?: HTMLElement | null +): boolean { + if (typeof document === 'undefined') { + return false; + } + const root = containerOrForm || document.body; + const invalidElement = root.querySelector( + '[aria-invalid="true"], input:invalid, select:invalid, textarea:invalid' + ); + if (invalidElement && typeof invalidElement.focus === 'function') { + invalidElement.focus(); + if (typeof invalidElement.scrollIntoView === 'function') { + invalidElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + return true; + } + return false; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cfe7dca..33d5702 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,4 +11,11 @@ export { createGroupItem, canAddGroupItem, canRemoveGroupItem, + moveGroupItem, + swapGroupItems, + insertGroupItem, + focusFirstInvalidField, } from './fieldGroup'; + +export * from './adapters'; +export * from './wizard'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c532dd1..a75af8c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,6 +39,11 @@ export interface FieldRendererProps { ariaInvalid?: boolean; ariaDescribedBy?: string; ariaRequired?: boolean; + min?: number | string; + max?: number | string; + step?: number | string; + accept?: string; + multiple?: boolean; } export interface FieldDescription { @@ -82,6 +87,12 @@ export interface FieldDescription { options?: | Properties[] | ((data: Properties, rootData?: Properties) => Properties[]); + min?: number | string; + max?: number | string; + step?: number | string; + accept?: string; + multiple?: boolean; + debounceMs?: number; className?: string; description?: unknown; /** diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index af0a50e..abd71bd 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -167,66 +167,3 @@ export async function validateFieldsAsync( return { valid: Object.keys(errors).length === 0, errors }; } - -/** - * Helper to adapt a Zod schema to a FieldDescription validate function. - */ -export function zodValidator( - schema: { - safeParse: (data: unknown) => { - success: boolean; - error?: { - errors: Array<{ message: string; path: Array }>; - }; - }; - }, - fieldName?: string -): (value: unknown, data: Properties) => string | string[] | undefined { - return (value: unknown, data: Properties) => { - const target = fieldName ? data || {} : value; - const result = schema.safeParse(target); - if (result.success || !result.error) { - return undefined; - } - const matchingErrors = fieldName - ? result.error.errors - .filter( - (err) => - err.path.join('.') === fieldName || err.path[0] === fieldName - ) - .map((err) => err.message) - : result.error.errors.map((err) => err.message); - - return matchingErrors.length > 0 ? matchingErrors : undefined; - }; -} - -/** - * Helper to adapt a Yup schema to a FieldDescription validate function. - */ -export function yupValidator( - schema: { validateSync: (value: unknown, opts?: unknown) => unknown }, - fieldName?: string -): (value: unknown, data: Properties) => string | string[] | undefined { - return (value: unknown, data: Properties) => { - try { - const target = fieldName ? data || {} : value; - schema.validateSync(target, { abortEarly: false }); - return undefined; - } catch (err: unknown) { - const yupErr = err as { - inner?: Array<{ path?: string; message: string }>; - message?: string; - }; - if (yupErr.inner && yupErr.inner.length > 0) { - const matching = fieldName - ? yupErr.inner - .filter((e) => e.path === fieldName) - .map((e) => e.message) - : yupErr.inner.map((e) => e.message); - return matching.length > 0 ? matching : undefined; - } - return yupErr.message ? [yupErr.message] : undefined; - } - }; -} diff --git a/packages/core/src/wizard.ts b/packages/core/src/wizard.ts new file mode 100644 index 0000000..29b8dcf --- /dev/null +++ b/packages/core/src/wizard.ts @@ -0,0 +1,50 @@ +import { FieldDescription, Properties } from './types'; +import { validateFields, ValidationResult } from './validation'; + +export interface FormStep { + id: string; + title: string; + description?: string; + fields: FieldDescription[]; +} + +export interface WizardState { + currentStepIndex: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + currentStep: FormStep; + steps: FormStep[]; + completedSteps: number[]; +} + +export function createWizardState( + steps: FormStep[], + initialStepIndex = 0 +): WizardState { + const safeIndex = Math.max(0, Math.min(initialStepIndex, steps.length - 1)); + return { + currentStepIndex: safeIndex, + totalSteps: steps.length, + isFirstStep: safeIndex === 0, + isLastStep: safeIndex === steps.length - 1, + currentStep: steps[safeIndex] || { id: '', title: '', fields: [] }, + steps, + completedSteps: [], + }; +} + +export function validateStep( + step: FormStep, + data: Properties +): ValidationResult { + return validateFields(step.fields, data); +} + +export function canGoNext(state: WizardState): boolean { + return state.currentStepIndex < state.totalSteps - 1; +} + +export function canGoPrev(state: WizardState): boolean { + return state.currentStepIndex > 0; +} diff --git a/packages/core/test/adapters.test.ts b/packages/core/test/adapters.test.ts new file mode 100644 index 0000000..511fcb9 --- /dev/null +++ b/packages/core/test/adapters.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + zodValidator, + yupValidator, + standardSchemaValidator, +} from '../src/adapters'; + +describe('Schema Adapters', () => { + it('handles zod schema validation', async () => { + const fakeZodSchema = { + safeParse: (data: any) => { + if (!data.email || !data.email.includes('@')) { + return { + success: false, + error: { + issues: [{ path: ['email'], message: 'Invalid email address' }], + }, + }; + } + return { success: true, data }; + }, + }; + + const validator = zodValidator(fakeZodSchema, 'email'); + const err = validator('invalid', { email: 'invalid' }); + expect(err).toEqual(['Invalid email address']); + + const valid = validator('test@example.com', { email: 'test@example.com' }); + expect(valid).toBeUndefined(); + }); + + it('handles yup schema validation', () => { + const fakeYupSchema = { + validateSync: (data: any) => { + if (!data.age || data.age < 18) { + const err: any = new Error('Validation failed'); + err.inner = [{ path: 'age', message: 'Must be at least 18' }]; + throw err; + } + }, + }; + + const validator = yupValidator(fakeYupSchema, 'age'); + const err = validator(15, { age: 15 }); + expect(err).toEqual(['Must be at least 18']); + + const valid = validator(20, { age: 20 }); + expect(valid).toBeUndefined(); + }); + + it('handles standard schema validation', () => { + const fakeStandardSchema = { + '~standard': { + validate: (data: any) => { + if (!data.name) { + return { + issues: [{ path: ['name'], message: 'Name is required' }], + }; + } + return { issues: [] }; + }, + }, + }; + + const validator = standardSchemaValidator(fakeStandardSchema, 'name'); + const err = validator('', { name: '' }); + expect(err).toEqual(['Name is required']); + + const valid = validator('John', { name: 'John' }); + expect(valid).toBeUndefined(); + }); +}); diff --git a/packages/core/test/validation.test.ts b/packages/core/test/validation.test.ts index 21cf224..aee996d 100644 --- a/packages/core/test/validation.test.ts +++ b/packages/core/test/validation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; import type { FieldDescription } from '../src'; +import { zodValidator, yupValidator } from '../src/adapters'; import { resolveDisabled, resolveOptions, @@ -7,8 +8,6 @@ import { validateField, validateFields, validateFieldsAsync, - zodValidator, - yupValidator, } from '../src/validation'; declare module '../src' { diff --git a/packages/core/test/wizard.test.ts b/packages/core/test/wizard.test.ts new file mode 100644 index 0000000..52135dc --- /dev/null +++ b/packages/core/test/wizard.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + createWizardState, + validateStep, + canGoNext, + canGoPrev, + FormStep, +} from '../src/wizard'; + +describe('Wizard Core Module', () => { + const steps: FormStep[] = [ + { + id: 'step1', + title: 'Personal Info', + fields: [ + { + name: 'name', + type: 'text', + required: true, + validate: (val) => (!val ? 'Name is required' : undefined), + }, + ], + }, + { + id: 'step2', + title: 'Account Settings', + fields: [ + { + name: 'email', + type: 'email', + required: true, + validate: (val) => (!val ? 'Email is required' : undefined), + }, + ], + }, + ]; + + it('creates wizard state properly', () => { + const state = createWizardState(steps, 0); + expect(state.currentStepIndex).toBe(0); + expect(state.isFirstStep).toBe(true); + expect(state.isLastStep).toBe(false); + expect(state.currentStep.id).toBe('step1'); + }); + + it('validates step correctly', () => { + const step1 = steps[0]; + const invalidRes = validateStep(step1, {}); + expect(invalidRes.valid).toBe(false); + expect(invalidRes.errors.name).toEqual(['Name is required']); + + const validRes = validateStep(step1, { name: 'Alice' }); + expect(validRes.valid).toBe(true); + }); + + it('navigates through steps correctly', () => { + const state0 = createWizardState(steps, 0); + expect(canGoNext(state0)).toBe(true); + expect(canGoPrev(state0)).toBe(false); + + const state1 = createWizardState(steps, 1); + expect(canGoNext(state1)).toBe(false); + expect(canGoPrev(state1)).toBe(true); + }); +}); diff --git a/packages/react/src/components/DynamicFormDevTools.tsx b/packages/react/src/components/DynamicFormDevTools.tsx new file mode 100644 index 0000000..b9cb010 --- /dev/null +++ b/packages/react/src/components/DynamicFormDevTools.tsx @@ -0,0 +1,236 @@ +import { FieldDescription, Properties } from '@dynamic-field-kit/core'; +import React, { useState } from 'react'; + +export interface DynamicFormDevToolsProps { + data: Properties; + errors?: Record; + touched?: Record; + isDirty?: boolean; + fields?: FieldDescription[]; + position?: 'bottom-right' | 'bottom-left'; +} + +export const DynamicFormDevTools: React.FC = ({ + data, + errors = {}, + touched = {}, + isDirty = false, + fields = [], + position = 'bottom-right', +}) => { + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState< + 'data' | 'errors' | 'meta' | 'fields' + >('data'); + + const errorCount = Object.keys(errors).length; + + const posStyle: React.CSSProperties = + position === 'bottom-left' + ? { left: '16px', bottom: '16px' } + : { right: '16px', bottom: '16px' }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ + 🛠️ Form DevTools + + +
+ +
+ {(['data', 'errors', 'meta', 'fields'] as const).map((tab) => ( + + ))} +
+ +
+ {activeTab === 'data' && ( +
+            {JSON.stringify(data, null, 2)}
+          
+ )} + + {activeTab === 'errors' && ( +
+ {Object.keys(errors).length === 0 ? ( + ✓ No validation errors + ) : ( + Object.entries(errors).map(([field, msgs]) => ( +
+ + {field}: + +
    + {msgs.map((m, i) => ( +
  • + {m} +
  • + ))} +
+
+ )) + )} +
+ )} + + {activeTab === 'meta' && ( +
+
+ isDirty: + + {String(isDirty)} + +
+
+ Touched Fields: +
+                {JSON.stringify(touched, null, 2)}
+              
+
+
+ )} + + {activeTab === 'fields' && ( +
+ {fields.length === 0 ? ( + + No field descriptions passed + + ) : ( + fields.map((f) => ( +
+
+ {f.name} +
+
+ type: {f.type} | required: {String(Boolean(f.required))} +
+
+ )) + )} +
+ )} +
+
+ ); +}; diff --git a/packages/react/src/defaultRenderers.tsx b/packages/react/src/defaultRenderers.tsx index 3fa18f8..67c2370 100644 --- a/packages/react/src/defaultRenderers.tsx +++ b/packages/react/src/defaultRenderers.tsx @@ -179,6 +179,136 @@ export const DefaultSelectRenderer: React.FC = ({ ); +export const DefaultRadioRenderer: React.FC = ({ + value, + onValueChange, + onBlur, + disabled, + readOnly, + required, + options = [], + id, + className, + ariaInvalid, + ariaDescribedBy, +}) => ( +
+ {options.map((opt, i) => { + const optVal = opt.value ?? opt.id ?? opt; + const optLabel = opt.label ?? opt.name ?? String(optVal); + const isChecked = String(value) === String(optVal); + const radioId = `${id || 'radio'}-${i}`; + return ( + + ); + })} +
+); + +export const DefaultRangeRenderer: React.FC = ({ + value, + onValueChange, + onBlur, + disabled, + readOnly, + required, + min, + max, + step, + id, + className, + ariaInvalid, + ariaDescribedBy, +}) => ( + onValueChange?.(Number(e.target.value))} + onBlur={onBlur} + disabled={disabled || readOnly} + required={required} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} + /> +); + +export const DefaultFileRenderer: React.FC = ({ + onValueChange, + onBlur, + disabled, + readOnly, + required, + accept, + multiple, + id, + className, + ariaInvalid, + ariaDescribedBy, +}) => ( + { + const files = e.target.files; + if (!files) { + return; + } + onValueChange?.(multiple ? Array.from(files) : files[0] || null); + }} + onBlur={onBlur} + disabled={disabled || readOnly} + required={required} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} + /> +); + +export const DefaultDateRenderer: React.FC = (props) => ( + +); + +export const DefaultTimeRenderer: React.FC = (props) => ( + +); + +export const DefaultDateTimeLocalRenderer: React.FC = ( + props +) => ; + +export const DefaultSwitchRenderer: React.FC = (props) => ( + +); + export const defaultRenderersMap: Record< string, React.FC @@ -190,6 +320,13 @@ export const defaultRenderersMap: Record< textarea: DefaultTextareaRenderer, checkbox: DefaultCheckboxRenderer, select: DefaultSelectRenderer, + radio: DefaultRadioRenderer, + range: DefaultRangeRenderer, + file: DefaultFileRenderer, + date: DefaultDateRenderer, + time: DefaultTimeRenderer, + 'datetime-local': DefaultDateTimeLocalRenderer, + switch: DefaultSwitchRenderer, }; export function getDefaultRenderer( diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1a4a8db..224eb6d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,7 +6,10 @@ export { layoutRegistry } from './layout'; export { default as DynamicInput } from './components/DynamicInput'; export { default as FieldInput } from './components/FieldInput'; export { default as MultiFieldInput } from './components/MultiFieldInput'; +export { DynamicFormDevTools } from './components/DynamicFormDevTools'; +export { useDynamicForm } from './useDynamicForm'; export { defaultRenderersMap, getDefaultRenderer } from './defaultRenderers'; + export { fieldRegistry, type ReactFieldRenderer, diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts new file mode 100644 index 0000000..72a58a1 --- /dev/null +++ b/packages/react/src/useDynamicForm.ts @@ -0,0 +1,139 @@ +import { + applyComputedValues, + FieldDescription, + Properties, + validateFields, +} from '@dynamic-field-kit/core'; +import React, { useCallback, useState } from 'react'; + +export interface UseDynamicFormOptions { + fields: FieldDescription[]; + initialValues?: Properties; + validateOnBlur?: boolean; + validateOnChange?: boolean; +} + +export interface UseDynamicFormResult { + data: Properties; + errors: Record; + isValid: boolean; + isDirty: boolean; + touched: Record; + setData: React.Dispatch>; + setFieldValue: (name: string, value: unknown) => void; + setFieldTouched: (name: string, isTouched?: boolean) => void; + handleChange: (newData: Properties) => void; + handleBlur: (fieldName: string) => void; + reset: (newValues?: Properties) => void; + validate: () => boolean; + handleSubmit: ( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) => (e?: React.FormEvent) => Promise; +} + +export function useDynamicForm({ + fields, + initialValues = {}, + validateOnBlur = true, + validateOnChange = false, +}: UseDynamicFormOptions): UseDynamicFormResult { + const [data, setData] = useState(() => + applyComputedValues(fields, initialValues) + ); + const [errors, setErrors] = useState>({}); + const [isDirty, setIsDirty] = useState(false); + const [touched, setTouched] = useState>({}); + + const validate = useCallback(() => { + const res = validateFields(fields, data); + setErrors(res.errors); + return res.valid; + }, [fields, data]); + + const handleChange = useCallback( + (newData: Properties) => { + const next = applyComputedValues(fields, newData); + setData(next); + setIsDirty(true); + + if (validateOnChange) { + const res = validateFields(fields, next); + setErrors(res.errors); + } + }, + [fields, validateOnChange] + ); + + const setFieldValue = useCallback( + (name: string, value: unknown) => { + handleChange({ ...data, [name]: value }); + }, + [data, handleChange] + ); + + const setFieldTouched = useCallback((name: string, isTouched = true) => { + setTouched((prev) => ({ ...prev, [name]: isTouched })); + }, []); + + const handleBlur = useCallback( + (fieldName: string) => { + setFieldTouched(fieldName, true); + if (validateOnBlur) { + const res = validateFields(fields, data); + setErrors(res.errors); + } + }, + [fields, data, validateOnBlur, setFieldTouched] + ); + + const reset = useCallback( + (newValues?: Properties) => { + const seed = newValues ?? initialValues; + const next = applyComputedValues(fields, seed); + setData(next); + setErrors({}); + setIsDirty(false); + setTouched({}); + }, + [fields, initialValues] + ); + + const handleSubmit = useCallback( + ( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) => + async (e?: React.FormEvent) => { + if (e && typeof e.preventDefault === 'function') { + e.preventDefault(); + } + const res = validateFields(fields, data); + setErrors(res.errors); + if (res.valid) { + await onValid(data); + } else if (onInvalid) { + onInvalid(res.errors); + } + }, + [fields, data] + ); + + const isValid = Object.keys(errors).length === 0; + + return { + data, + errors, + isValid, + isDirty, + touched, + setData, + setFieldValue, + setFieldTouched, + handleChange, + handleBlur, + reset, + validate, + handleSubmit, + }; +} diff --git a/packages/react/test/extendedFeatures.test.tsx b/packages/react/test/extendedFeatures.test.tsx new file mode 100644 index 0000000..1373633 --- /dev/null +++ b/packages/react/test/extendedFeatures.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + DynamicInput, + useDynamicForm, + DynamicFormDevTools, + FieldDescription, +} from '../src'; + +describe('React Extended Features', () => { + it('renders radio renderer and updates value', () => { + const options = [ + { label: 'Male', value: 'male' }, + { label: 'Female', value: 'female' }, + ]; + + const handleChange = vi.fn(); + render( + + ); + + const femaleRadio = screen.getByLabelText('Female'); + expect(femaleRadio).not.toBeChecked(); + + fireEvent.click(femaleRadio); + expect(handleChange).toHaveBeenCalledWith('female'); + }); + + it('renders range slider', () => { + const handleChange = vi.fn(); + render( + + ); + + const slider = screen.getByRole('slider'); + expect(slider).toBeInTheDocument(); + }); + + it('useDynamicForm hook initializes and handles submission', async () => { + const fields: FieldDescription[] = [ + { name: 'name', type: 'text', required: true }, + ]; + + function TestComponent() { + const { data, setFieldValue, reset } = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + }); + + return ( +
+ {String(data.name)} + + +
+ ); + } + + render(); + expect(screen.getByTestId('name-val').textContent).toBe('Alice'); + + fireEvent.click(screen.getByText('Change Name')); + expect(screen.getByTestId('name-val').textContent).toBe('Bob'); + + fireEvent.click(screen.getByText('Reset Form')); + expect(screen.getByTestId('name-val').textContent).toBe('Alice'); + }); + + it('renders DevTools button and opens overlay', () => { + render( + + ); + + const devToolsBtn = screen.getByText('🔍 DevTools'); + expect(devToolsBtn).toBeInTheDocument(); + + fireEvent.click(devToolsBtn); + expect(screen.getByText('🛠️ Form DevTools')).toBeInTheDocument(); + }); +}); diff --git a/packages/vue/src/components/DynamicFormDevTools.ts b/packages/vue/src/components/DynamicFormDevTools.ts new file mode 100644 index 0000000..396f526 --- /dev/null +++ b/packages/vue/src/components/DynamicFormDevTools.ts @@ -0,0 +1,313 @@ +import { FieldDescription, Properties } from '@dynamic-field-kit/core'; +import { defineComponent, h, PropType, ref } from 'vue'; + +export const DynamicFormDevTools = defineComponent({ + name: 'DynamicFormDevTools', + props: { + data: { + type: Object as PropType, + required: true, + }, + errors: { + type: Object as PropType>, + default: () => ({}), + }, + touched: { + type: Object as PropType>, + default: () => ({}), + }, + isDirty: { + type: Boolean, + default: false, + }, + fields: { + type: Array as PropType, + default: () => [], + }, + position: { + type: String as PropType<'bottom-right' | 'bottom-left'>, + default: 'bottom-right', + }, + }, + setup(props) { + const isOpen = ref(false); + const activeTab = ref<'data' | 'errors' | 'meta' | 'fields'>('data'); + + return () => { + const errorKeys = Object.keys(props.errors || {}); + const errorCount = errorKeys.length; + + const posStyle = + props.position === 'bottom-left' + ? { left: '16px', bottom: '16px' } + : { right: '16px', bottom: '16px' }; + + if (!isOpen.value) { + return h( + 'button', + { + type: 'button', + onClick: () => (isOpen.value = true), + style: { + position: 'fixed', + ...posStyle, + zIndex: 99999, + background: '#1e293b', + color: '#f8fafc', + border: '1px solid #334155', + borderRadius: '20px', + padding: '8px 14px', + fontSize: '12px', + fontWeight: 600, + cursor: 'pointer', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + }, + [ + h('span', '🔍 DevTools'), + errorCount > 0 + ? h( + 'span', + { + style: { + background: '#ef4444', + color: '#fff', + borderRadius: '10px', + padding: '2px 6px', + fontSize: '10px', + }, + }, + String(errorCount) + ) + : null, + ] + ); + } + + return h( + 'div', + { + style: { + position: 'fixed', + ...posStyle, + zIndex: 99999, + width: '360px', + maxHeight: '420px', + background: '#0f172a', + color: '#f8fafc', + border: '1px solid #334155', + borderRadius: '12px', + boxShadow: '0 10px 25px rgba(0,0,0,0.3)', + display: 'flex', + flexDirection: 'column', + fontFamily: 'monospace, sans-serif', + fontSize: '12px', + overflow: 'hidden', + }, + }, + [ + // Header + h( + 'div', + { + style: { + padding: '10px 14px', + background: '#1e293b', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + borderBottom: '1px solid #334155', + }, + }, + [ + h( + 'span', + { style: { fontWeight: 'bold', color: '#38bdf8' } }, + '🛠️ Form DevTools' + ), + h( + 'button', + { + type: 'button', + onClick: () => (isOpen.value = false), + style: { + background: 'transparent', + border: 'none', + color: '#94a3b8', + fontSize: '14px', + cursor: 'pointer', + }, + }, + '✕' + ), + ] + ), + // Tabs + h( + 'div', + { + style: { + display: 'flex', + background: '#1e293b', + borderBottom: '1px solid #334155', + }, + }, + (['data', 'errors', 'meta', 'fields'] as const).map((tab) => + h( + 'button', + { + key: tab, + type: 'button', + onClick: () => (activeTab.value = tab), + style: { + flex: 1, + padding: '6px 0', + background: + activeTab.value === tab ? '#0f172a' : 'transparent', + color: activeTab.value === tab ? '#38bdf8' : '#94a3b8', + border: 'none', + cursor: 'pointer', + textTransform: 'capitalize', + fontSize: '11px', + fontWeight: activeTab.value === tab ? 'bold' : 'normal', + }, + }, + `${tab}${ + tab === 'errors' && errorCount > 0 ? ` (${errorCount})` : '' + }` + ) + ) + ), + // Content + h('div', { style: { padding: '12px', overflowY: 'auto', flex: 1 } }, [ + activeTab.value === 'data' + ? h( + 'pre', + { + style: { + margin: 0, + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + color: '#a7f3d0', + }, + }, + JSON.stringify(props.data, null, 2) + ) + : null, + activeTab.value === 'errors' + ? h( + 'div', + errorCount === 0 + ? [ + h( + 'span', + { style: { color: '#4ade80' } }, + '✓ No validation errors' + ), + ] + : Object.entries(props.errors).map(([field, msgs]) => + h( + 'div', + { key: field, style: { marginBottom: '8px' } }, + [ + h( + 'span', + { + style: { color: '#f87171', fontWeight: 'bold' }, + }, + `${field}:` + ), + h( + 'ul', + { style: { margin: '4px 0 0 16px', padding: 0 } }, + msgs.map((m, i) => + h( + 'li', + { key: i, style: { color: '#fca5a5' } }, + m + ) + ) + ), + ] + ) + ) + ) + : null, + activeTab.value === 'meta' + ? h('div', [ + h('div', { style: { marginBottom: '6px' } }, [ + h('span', { style: { color: '#94a3b8' } }, 'isDirty: '), + h( + 'span', + { + style: { color: props.isDirty ? '#facc15' : '#4ade80' }, + }, + String(props.isDirty) + ), + ]), + h('div', [ + h( + 'span', + { style: { color: '#94a3b8' } }, + 'Touched Fields:' + ), + h( + 'pre', + { style: { margin: '4px 0 0 0', color: '#cbd5e1' } }, + JSON.stringify(props.touched, null, 2) + ), + ]), + ]) + : null, + activeTab.value === 'fields' + ? h( + 'div', + props.fields.length === 0 + ? [ + h( + 'span', + { style: { color: '#94a3b8' } }, + 'No field descriptions passed' + ), + ] + : props.fields.map((f) => + h( + 'div', + { + key: f.name, + style: { + padding: '6px', + marginBottom: '6px', + background: '#1e293b', + borderRadius: '4px', + }, + }, + [ + h( + 'div', + { + style: { color: '#38bdf8', fontWeight: 'bold' }, + }, + f.name + ), + h( + 'div', + { style: { color: '#94a3b8', fontSize: '10px' } }, + `type: ${f.type} | required: ${String( + Boolean(f.required) + )}` + ), + ] + ) + ) + ) + : null, + ]), + ] + ); + }; + }, +}); diff --git a/packages/vue/src/defaultRenderers.ts b/packages/vue/src/defaultRenderers.ts index 0b1af92..e750184 100644 --- a/packages/vue/src/defaultRenderers.ts +++ b/packages/vue/src/defaultRenderers.ts @@ -245,6 +245,205 @@ export const DefaultSelectRenderer = defineComponent({ }, }); +export const DefaultRadioRenderer = defineComponent({ + name: 'DefaultRadioRenderer', + props: { + value: null, + onValueChange: Function as PropType<(val: unknown) => void>, + 'onUpdate:value': Function as PropType<(val: unknown) => void>, + onBlur: Function as PropType<() => void>, + disabled: Boolean, + readOnly: Boolean, + required: Boolean, + options: { + type: Array as PropType, + default: () => [], + }, + id: String, + class: String, + ariaInvalid: Boolean, + ariaDescribedBy: String, + }, + setup(props) { + return () => { + const emitChange = props['onUpdate:value'] || props.onValueChange; + const radioNodes = (props.options || []).map((opt, i) => { + const optVal = opt.value ?? opt.id ?? opt; + const optLabel = opt.label ?? opt.name ?? String(optVal); + const isChecked = String(props.value) === String(optVal); + const radioId = `${props.id || 'radio'}-${i}`; + return h( + 'label', + { + key: String(optVal) + i, + style: { + marginRight: '12px', + display: 'inline-flex', + alignItems: 'center', + }, + }, + [ + h('input', { + type: 'radio', + id: radioId, + name: props.id, + value: String(optVal), + checked: isChecked, + onChange: () => emitChange?.(optVal), + disabled: props.disabled || props.readOnly, + required: props.required, + 'aria-invalid': props.ariaInvalid, + 'aria-describedby': props.ariaDescribedBy, + }), + h('span', { style: { marginLeft: '4px' } }, String(optLabel)), + ] + ); + }); + + return h( + 'div', + { + class: `dfk-radio-group ${props.class || ''}`, + id: props.id, + onBlur: props.onBlur, + }, + radioNodes + ); + }; + }, +}); + +export const DefaultRangeRenderer = defineComponent({ + name: 'DefaultRangeRenderer', + props: { + value: null, + onValueChange: Function as PropType<(val: unknown) => void>, + 'onUpdate:value': Function as PropType<(val: unknown) => void>, + onBlur: Function as PropType<() => void>, + disabled: Boolean, + readOnly: Boolean, + required: Boolean, + min: [Number, String], + max: [Number, String], + step: [Number, String], + id: String, + class: String, + ariaInvalid: Boolean, + ariaDescribedBy: String, + }, + setup(props) { + return () => { + const emitChange = props['onUpdate:value'] || props.onValueChange; + return h('input', { + type: 'range', + id: props.id, + class: props.class, + value: (props.value as number) ?? props.min ?? 0, + min: props.min, + max: props.max, + step: props.step, + onInput: (e: Event) => + emitChange?.(Number((e.target as HTMLInputElement).value)), + onBlur: props.onBlur, + disabled: props.disabled || props.readOnly, + required: props.required, + 'aria-invalid': props.ariaInvalid, + 'aria-describedby': props.ariaDescribedBy, + }); + }; + }, +}); + +export const DefaultFileRenderer = defineComponent({ + name: 'DefaultFileRenderer', + props: { + value: null, + onValueChange: Function as PropType<(val: unknown) => void>, + 'onUpdate:value': Function as PropType<(val: unknown) => void>, + onBlur: Function as PropType<() => void>, + disabled: Boolean, + readOnly: Boolean, + required: Boolean, + accept: String, + multiple: Boolean, + id: String, + class: String, + ariaInvalid: Boolean, + ariaDescribedBy: String, + }, + setup(props) { + return () => { + const emitChange = props['onUpdate:value'] || props.onValueChange; + return h('input', { + type: 'file', + id: props.id, + class: props.class, + accept: props.accept, + multiple: props.multiple, + onChange: (e: Event) => { + const files = (e.target as HTMLInputElement).files; + if (!files) { + return; + } + emitChange?.(props.multiple ? Array.from(files) : files[0] || null); + }, + onBlur: props.onBlur, + disabled: props.disabled || props.readOnly, + required: props.required, + 'aria-invalid': props.ariaInvalid, + 'aria-describedby': props.ariaDescribedBy, + }); + }; + }, +}); + +export const DefaultDateRenderer = defineComponent({ + name: 'DefaultDateRenderer', + setup(props, { attrs }) { + return () => + h(DefaultTextRenderer as any, { + ...props, + ...attrs, + inputType: 'date', + }); + }, +}); + +export const DefaultTimeRenderer = defineComponent({ + name: 'DefaultTimeRenderer', + setup(props, { attrs }) { + return () => + h(DefaultTextRenderer as any, { + ...props, + ...attrs, + inputType: 'time', + }); + }, +}); + +export const DefaultDateTimeLocalRenderer = defineComponent({ + name: 'DefaultDateTimeLocalRenderer', + setup(props, { attrs }) { + return () => + h(DefaultTextRenderer as any, { + ...props, + ...attrs, + inputType: 'datetime-local', + }); + }, +}); + +export const DefaultSwitchRenderer = defineComponent({ + name: 'DefaultSwitchRenderer', + setup(props, { attrs }) { + return () => + h(DefaultCheckboxRenderer as any, { + ...props, + ...attrs, + }); + }, +}); + export const defaultRenderersMap: Record = { text: DefaultTextRenderer, number: DefaultNumberRenderer, @@ -253,6 +452,13 @@ export const defaultRenderersMap: Record = { textarea: DefaultTextareaRenderer, checkbox: DefaultCheckboxRenderer, select: DefaultSelectRenderer, + radio: DefaultRadioRenderer, + range: DefaultRangeRenderer, + file: DefaultFileRenderer, + date: DefaultDateRenderer, + time: DefaultTimeRenderer, + 'datetime-local': DefaultDateTimeLocalRenderer, + switch: DefaultSwitchRenderer, }; export function getDefaultRenderer(type: string): any { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 2414f28..384f3db 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -12,6 +12,8 @@ export { useFieldRegistry, FieldRegistryKey, } from './fieldRegistryContext'; +export { useDynamicForm } from './useDynamicForm'; +export { DynamicFormDevTools } from './components/DynamicFormDevTools'; // Re-export selected core APIs export { diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts new file mode 100644 index 0000000..75ab1fa --- /dev/null +++ b/packages/vue/src/useDynamicForm.ts @@ -0,0 +1,115 @@ +import { + applyComputedValues, + FieldDescription, + Properties, + validateFields, +} from '@dynamic-field-kit/core'; +import { computed, ref } from 'vue'; + +export interface UseDynamicFormOptions { + fields: FieldDescription[]; + initialValues?: Properties; + validateOnBlur?: boolean; + validateOnChange?: boolean; +} + +export function useDynamicForm({ + fields, + initialValues = {}, + validateOnBlur = true, + validateOnChange = false, +}: UseDynamicFormOptions) { + const data = ref(applyComputedValues(fields, initialValues)); + const errors = ref>({}); + const isDirty = ref(false); + const touched = ref>({}); + const isSubmitting = ref(false); + const isSubmitted = ref(false); + + const isValid = computed(() => Object.keys(errors.value).length === 0); + + function validate() { + const res = validateFields(fields, data.value); + errors.value = res.errors; + return res.valid; + } + + function handleChange(newData: Properties) { + const next = applyComputedValues(fields, newData); + data.value = next; + isDirty.value = true; + + if (validateOnChange) { + const res = validateFields(fields, next); + errors.value = res.errors; + } + } + + function setFieldValue(name: string, value: unknown) { + handleChange({ ...data.value, [name]: value }); + } + + function setFieldTouched(name: string, isTouched = true) { + touched.value = { ...touched.value, [name]: isTouched }; + } + + function handleBlur(fieldName: string) { + setFieldTouched(fieldName, true); + if (validateOnBlur) { + const res = validateFields(fields, data.value); + errors.value = res.errors; + } + } + + function reset(newValues?: Properties) { + const seed = newValues ?? initialValues; + const next = applyComputedValues(fields, seed); + data.value = next; + errors.value = {}; + isDirty.value = false; + touched.value = {}; + isSubmitting.value = false; + isSubmitted.value = false; + } + + function handleSubmit( + onValid: (data: Properties) => void | Promise, + onInvalid?: (errors: Record) => void + ) { + return async (e?: Event) => { + if (e && typeof e.preventDefault === 'function') { + e.preventDefault(); + } + isSubmitting.value = true; + try { + const res = validateFields(fields, data.value); + errors.value = res.errors; + isSubmitted.value = true; + if (res.valid) { + await onValid(data.value); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + isSubmitting.value = false; + } + }; + } + + return { + data, + errors, + isValid, + isDirty, + touched, + isSubmitting, + isSubmitted, + setFieldValue, + setFieldTouched, + handleChange, + handleBlur, + reset, + validate, + handleSubmit, + }; +} diff --git a/packages/vue/test/extendedFeatures.test.ts b/packages/vue/test/extendedFeatures.test.ts new file mode 100644 index 0000000..10362b1 --- /dev/null +++ b/packages/vue/test/extendedFeatures.test.ts @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { + FieldInput, + useDynamicForm, + DynamicFormDevTools, + FieldDescription, +} from '../src'; + +describe('Vue Extended Features', () => { + it('useDynamicForm composable works properly', () => { + const fields: FieldDescription[] = [ + { name: 'name', type: 'text', required: true }, + ]; + + const { data, setFieldValue, reset } = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + }); + + expect(data.value.name).toBe('Alice'); + + setFieldValue('name', 'Bob'); + expect(data.value.name).toBe('Bob'); + + reset(); + expect(data.value.name).toBe('Alice'); + }); + + it('renders radio renderer in Vue', async () => { + const field: FieldDescription = { + name: 'plan', + type: 'radio', + label: 'Plan', + options: [ + { label: 'Free', value: 'free' }, + { label: 'Pro', value: 'pro' }, + ], + }; + + const wrapper = mount(FieldInput, { + props: { + fieldDescription: field, + renderInfos: { plan: 'free' }, + onValueChangeField: vi.fn(), + }, + }); + + expect(wrapper.find('.dfk-radio-group').exists()).toBe(true); + }); + + it('renders DevTools button in Vue', async () => { + const wrapper = mount(DynamicFormDevTools, { + props: { + data: { name: 'Alice' }, + isDirty: false, + }, + }); + + expect(wrapper.text()).toContain('🔍 DevTools'); + await wrapper.find('button').trigger('click'); + expect(wrapper.text()).toContain('🛠️ Form DevTools'); + }); +}); From d580d5cbb163e217e89a0a403ed4bb09672c5b02 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:09:02 +0700 Subject: [PATCH 03/20] docs: update READMEs and example app with enterprise features --- README.md | 9 + example/react-app/app/new-features/page.tsx | 261 +++++++++----------- packages/react/README.md | 2 + 3 files changed, 131 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index a8a5447..9e10f36 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,15 @@ A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, b - Framework-agnostic core (works with React, Angular, Vue, or vanilla JS) - Ideal for form builders & design systems +### 🚀 Enterprise Features (v1.4+) + +- **Form State Hook / Composable / Signal Store**: `useDynamicForm` for React & Vue 3, `createDynamicFormStore` for Angular Signals. +- **Extended HTML5 Renderers**: Built-in support for `radio`, `range`, `file`, `date`, `time`, `datetime-local`, and `switch`. +- **Schema Validation Adapters**: Integrated `zodValidator`, `yupValidator`, `valibotValidator`, and Standard Schema adapters. +- **Multi-Step Form Wizard Engine**: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`. +- **Interactive Form DevTools**: Floating overlay component (``) for realtime debugging. +- **Group Array Manipulation Helpers**: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, and `focusFirstInvalidField`. + --- ## 📦 Packages diff --git a/example/react-app/app/new-features/page.tsx b/example/react-app/app/new-features/page.tsx index e36a973..e197352 100644 --- a/example/react-app/app/new-features/page.tsx +++ b/example/react-app/app/new-features/page.tsx @@ -1,13 +1,12 @@ 'use client'; +import { FieldDescription, validators } from '@dynamic-field-kit/core'; import { - FieldDescription, - validators, - validateFieldsAsync, -} from '@dynamic-field-kit/core'; -import { MultiFieldInput } from '@dynamic-field-kit/react'; + MultiFieldInput, + useDynamicForm, + DynamicFormDevTools, +} from '@dynamic-field-kit/react'; import Link from 'next/link'; -import { useState } from 'react'; import '../../lib/fieldRegistry'; const fields: FieldDescription[] = [ @@ -22,34 +21,27 @@ const fields: FieldDescription[] = [ validate: validators.required('Vui lòng chọn quốc gia'), }, { - name: 'city', - type: 'select', - label: '2. Thành phố (Phụ thuộc vào Quốc gia đã chọn)', - // Dynamic options function evaluated on data change - options: (data) => { - if (data.country === 'VN') { - return [ - { label: 'Hà Nội', value: 'HN' }, - { label: 'TP. Hồ Chí Minh', value: 'HCM' }, - { label: 'Đà Nẵng', value: 'DN' }, - ]; - } - if (data.country === 'US') { - return [ - { label: 'New York', value: 'NY' }, - { label: 'Los Angeles', value: 'LA' }, - { label: 'Chicago', value: 'CHI' }, - ]; - } - return []; - }, - disabledCondition: (data) => !data.country, - validate: validators.required('Vui lòng chọn thành phố'), + name: 'gender', + type: 'radio', + label: '2. Giới tính (Extended HTML5 Radio)', + options: [ + { label: 'Nam', value: 'male' }, + { label: 'Nữ', value: 'female' }, + { label: 'Khác', value: 'other' }, + ], + }, + { + name: 'satisfaction', + type: 'range', + label: '3. Mức độ hài lòng (Extended Range Slider)', + min: 1, + max: 10, + step: 1, }, { name: 'email', - type: 'text', - label: '3. Email (Built-in Validators: required + email)', + type: 'email', + label: '4. Email (Built-in Validators)', placeholder: 'example@domain.com', validate: validators.compose( validators.required('Email bắt buộc'), @@ -57,72 +49,33 @@ const fields: FieldDescription[] = [ ), }, { - name: 'username', - type: 'text', - label: '4. Username (Async validation simulation)', - placeholder: 'Nhập username (thử "admin")', - validate: async (value) => { - if (!value) { - return 'Username bắt buộc'; - } - // Simulate API call check - if (String(value).toLowerCase() === 'admin') { - return 'Tên "admin" đã tồn tại, vui lòng chọn tên khác'; - } - return undefined; - }, - }, - { - name: 'enableExtra', - type: 'select', - label: '5. Hiển thị trường bổ sung? (appearCondition)', - options: [ - { label: 'Không', value: 'no' }, - { label: 'Có', value: 'yes' }, - ], + name: 'birthDate', + type: 'date', + label: '5. Ngày sinh (Native Date Picker)', }, { - name: 'note', - type: 'text', - label: 'Ghi chú thêm (Xuất hiện khi chọn "Có")', - appearCondition: (data) => data.enableExtra === 'yes', - }, - { - name: 'lockAll', - type: 'select', - label: '6. Khóa trường số điện thoại? (disabledCondition)', - options: [ - { label: 'Mở khóa', value: 'unlocked' }, - { label: 'Khóa (Disabled)', value: 'locked' }, - ], - }, - { - name: 'phone', - type: 'text', - label: 'Số điện thoại', - placeholder: '0901234567', - disabledCondition: (data) => data.lockAll === 'locked', + name: 'subscribeNewsletter', + type: 'switch', + label: '6. Nhận bản tin ưu đãi (Switch Toggle)', }, ]; export default function NewFeaturesPage() { - const [data, setData] = useState>({ country: 'VN' }); - const [errors, setErrors] = useState>({}); - const [validating, setValidating] = useState(false); - - const handleValidate = async () => { - setValidating(true); - // Test async validation - const res = await validateFieldsAsync(fields, data); - setErrors(res.errors); - setValidating(false); - }; + const form = useDynamicForm({ + fields, + initialValues: { + country: 'VN', + satisfaction: 8, + subscribeNewsletter: true, + }, + validateOnBlur: true, + }); return (
← Demo Cơ Bản (Legacy) - ✨ Demo Tính Năng Mới (v1.3+) + + ✨ Demo Enterprise Features (v1.4+) +

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

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

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

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

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

- Validation Errors: -

-
-              {JSON.stringify(errors, null, 2)}
-            
- - )}
+ + {/* Floating DevTools */} +
); } diff --git a/packages/react/README.md b/packages/react/README.md index 572bdae..40867a9 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -19,6 +19,8 @@ Note: `@dynamic-field-kit/core`, `react`, and `react-dom` are **peer dependencie - `DynamicInput` - `FieldInput` - `MultiFieldInput` +- `useDynamicForm` +- `DynamicFormDevTools` - `layoutRegistry` - `fieldRegistry` - `FieldRegistry` (class, for scoped registries) From fc6127953747c48bcdaec9a5540988ce65ccb483 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:49:43 +0700 Subject: [PATCH 04/20] fix(core): parse schemas synchronously and make adapter target explicit Why: zodValidator checked safeParseAsync before safeParse. Real Zod schemas expose both, so the async branch always won and the validator returned a Promise. validateField treats a Promise as "no errors", and useDynamicForm only ever calls the synchronous validateFields - so a Zod-validated form silently reported valid for invalid data. yupValidator had a related bug: Yup throws a plain Error (not a ValidationError) when a test is async, and that internal message "Validation test of type ... returned a Promise" was surfaced to the user as a form error. The payload rule was also a guess - `value !== undefined ? value : data` - which picked the field value for a form-level schema, and differed from standardSchemaValidator. What: - Prefer sync parsing (safeParse / validateSync), falling back to the async path only when the schema genuinely requires it. - Distinguish a Yup ValidationError from its async-test error, and retry asynchronously instead of leaking the internal message. - Replace the payload heuristic with an explicit SchemaValidatorOptions `{ field, target: 'form' | 'field' }`, applied identically by all three adapters. The `zodValidator(schema, 'email')` shorthand still works. - Add zod and yup as core devDependencies; the adapters were previously only exercised against hand-rolled fakes that hid both bugs. How to test: npm run test --workspace=@dynamic-field-kit/core --- package-lock.json | 61 +++- packages/core/package.json | 4 +- packages/core/src/adapters.ts | 274 +++++++++++------- .../core/test/adaptersRealSchemas.test.ts | 201 +++++++++++++ packages/core/test/validation.test.ts | 8 +- 5 files changed, 440 insertions(+), 108 deletions(-) create mode 100644 packages/core/test/adaptersRealSchemas.test.ts diff --git a/package-lock.json b/package-lock.json index 022b82c..a9ef31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18120,6 +18120,13 @@ "dev": true, "license": "MIT" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -20245,6 +20252,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -20319,6 +20333,13 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -22333,6 +22354,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zone.js": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", @@ -23100,7 +23157,9 @@ "@vitest/coverage-istanbul": "^0.34.6", "rimraf": "^6.1.3", "tsup": "^8.0.1", - "vitest": "^0.34.0" + "vitest": "^0.34.0", + "yup": "^1.7.1", + "zod": "^4.4.3" } }, "packages/core/node_modules/@vitest/coverage-istanbul": { diff --git a/packages/core/package.json b/packages/core/package.json index 3ba4227..478295b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,8 @@ "@vitest/coverage-istanbul": "^0.34.6", "rimraf": "^6.1.3", "tsup": "^8.0.1", - "vitest": "^0.34.0" + "vitest": "^0.34.0", + "yup": "^1.7.1", + "zod": "^4.4.3" } } diff --git a/packages/core/src/adapters.ts b/packages/core/src/adapters.ts index e7b76a4..967a4d1 100644 --- a/packages/core/src/adapters.ts +++ b/packages/core/src/adapters.ts @@ -12,155 +12,225 @@ export type FieldValidatorFunction = ( rootData?: Properties ) => FieldValidatorResult; +export interface SchemaValidatorOptions { + /** + * Name of the field this validator is attached to. Its live value is patched + * into the form data before parsing, and only issues whose path points at + * this field are surfaced. + */ + field?: string; + /** + * What the schema describes: + * - `'form'` (default) - an object schema covering the whole form; the form + * data object is parsed. + * - `'field'` - a scalar schema covering one value (e.g. `z.string().email()`); + * the field's value is parsed on its own. + */ + target?: 'form' | 'field'; +} + +/** Accepts the shorthand `zodValidator(schema, 'email')` or an options object. */ +function normalizeOptions( + fieldNameOrOptions?: string | SchemaValidatorOptions +): Required> & { field?: string } { + if (typeof fieldNameOrOptions === 'string') { + return { field: fieldNameOrOptions, target: 'form' }; + } + return { + field: fieldNameOrOptions?.field, + target: fieldNameOrOptions?.target ?? 'form', + }; +} + +/** + * Payload an adapter parses. + * + * For a `'form'` schema the form data object is parsed, with the field's live + * `value` patched in when a field name is known, so cross-field rules still see + * the rest of the form. For a `'field'` schema the value is parsed on its own. + */ +function buildPayload( + data: Properties, + value: unknown, + field: string | undefined, + target: 'form' | 'field' +): unknown { + if (target === 'field') { + return value; + } + return field ? { ...data, [field]: value } : data; +} + +/** Keeps only the issues belonging to `fieldName`, then maps them to messages. */ +function toMessages( + issues: any[], + fieldName: string | undefined, + matchPath: (issue: any, fieldName: string) => boolean +): string[] | undefined { + const matched = fieldName + ? issues.filter((issue) => matchPath(issue, fieldName)) + : issues; + return matched.length === 0 + ? undefined + : matched.map((issue: any) => issue.message); +} + +/** Zod/Standard-Schema issue paths are arrays: `['address', 'street']`. */ +function matchesArrayPath(issue: any, fieldName: string): boolean { + return Array.isArray(issue.path) && issue.path.includes(fieldName); +} + +/** Yup issue paths are strings: `'address.street'`. */ +function matchesStringPath(issue: any, fieldName: string): boolean { + return issue.path === fieldName; +} + +function zodResultToMessages( + res: any, + fieldName?: string +): string[] | undefined { + if (res.success) { + return undefined; + } + const issues = res.error?.issues || res.error?.errors || []; + return toMessages(issues, fieldName, matchesArrayPath); +} + /** * Validates whole form data or a specific field using a Zod schema. + * + * Parses synchronously so the result is usable by the synchronous + * `validateFields`. Schemas containing async refinements cannot be parsed + * synchronously - for those, a Promise is returned and you must validate via + * `validateFieldsAsync`. */ export function zodValidator( schema: any, - fieldName?: string + fieldNameOrOptions?: string | SchemaValidatorOptions ): FieldValidatorFunction { - return (value: unknown, data: Properties) => { - const payload = fieldName - ? { ...data, [fieldName]: value } - : value !== undefined - ? value - : data; + const { field, target } = normalizeOptions(fieldNameOrOptions); + // A scalar schema reports issues with an empty path, so there is nothing to + // filter by - every issue belongs to this field. + const filterBy = target === 'field' ? undefined : field; - if (typeof schema.safeParseAsync === 'function') { - return schema.safeParseAsync(payload).then((res: any) => { - if (res.success) { - return undefined; - } - const issues = res.error?.issues || res.error?.errors || []; - const matched = fieldName - ? issues.filter( - (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) - ) - : issues; - if (matched.length === 0) { - return undefined; - } - return matched.map((i: any) => i.message); - }); - } + return (value: unknown, data: Properties) => { + const payload = buildPayload(data, value, field, target); if (typeof schema.safeParse === 'function') { - const res = schema.safeParse(payload); - if (res.success) { - return undefined; - } - const issues = res.error?.issues || res.error?.errors || []; - const matched = fieldName - ? issues.filter( - (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) - ) - : issues; - if (matched.length === 0) { - return undefined; + try { + return zodResultToMessages(schema.safeParse(payload), filterBy); + } catch { + // Zod throws when the schema needs async parsing; fall through. } - return matched.map((i: any) => i.message); + } + + if (typeof schema.safeParseAsync === 'function') { + return schema + .safeParseAsync(payload) + .then((res: any) => zodResultToMessages(res, filterBy)); } return undefined; }; } +/** A Yup ValidationError, as opposed to Yup's "test returned a Promise" error. */ +function isYupValidationError(err: any): boolean { + return err?.name === 'ValidationError' || Array.isArray(err?.inner); +} + +function yupErrorToMessages( + err: any, + fieldName?: string +): string[] | undefined { + if (Array.isArray(err.inner) && err.inner.length > 0) { + return toMessages(err.inner, fieldName, matchesStringPath); + } + if (fieldName && err.path !== undefined && err.path !== fieldName) { + return undefined; + } + return err.message ? [err.message] : undefined; +} + /** * Validates whole form data or a specific field using a Yup schema. + * + * Validates synchronously so the result is usable by the synchronous + * `validateFields`. Schemas with async `.test()` rules cannot be validated + * synchronously - for those, a Promise is returned and you must validate via + * `validateFieldsAsync`. */ export function yupValidator( schema: any, - fieldName?: string + fieldNameOrOptions?: string | SchemaValidatorOptions ): FieldValidatorFunction { + const { field, target } = normalizeOptions(fieldNameOrOptions); + const filterBy = target === 'field' ? undefined : field; + return (value: unknown, data: Properties) => { - const payload = fieldName - ? { ...data, [fieldName]: value } - : value !== undefined - ? value - : data; - - try { - if (typeof schema.validateSync === 'function') { + const payload = buildPayload(data, value, field, target); + + if (typeof schema.validateSync === 'function') { + try { schema.validateSync(payload, { abortEarly: false }); return undefined; - } - } catch (err: any) { - if (err.inner && Array.isArray(err.inner)) { - const matched = fieldName - ? err.inner.filter((i: any) => i.path === fieldName) - : err.inner; - if (matched.length === 0) { - return undefined; + } catch (err: any) { + if (isYupValidationError(err)) { + return yupErrorToMessages(err, filterBy); + } + // Not a ValidationError: Yup throws a plain Error when a test is + // async. Retry asynchronously so that internal message never reaches + // the user - but only if there is an async path to retry on. + if (typeof schema.validate !== 'function') { + return yupErrorToMessages(err, filterBy); } - return matched.map((i: any) => i.message); } - return err.message ? [err.message] : undefined; } if (typeof schema.validate === 'function') { return schema .validate(payload, { abortEarly: false }) .then(() => undefined) - .catch((err: any) => { - if (err.inner && Array.isArray(err.inner)) { - const matched = fieldName - ? err.inner.filter((i: any) => i.path === fieldName) - : err.inner; - if (matched.length === 0) { - return undefined; - } - return matched.map((i: any) => i.message); - } - return err.message ? [err.message] : undefined; - }); + .catch((err: any) => yupErrorToMessages(err, filterBy)); } return undefined; }; } +function standardResultToMessages( + result: any, + fieldName?: string +): string[] | undefined { + if (!result.issues || result.issues.length === 0) { + return undefined; + } + return toMessages(result.issues, fieldName, matchesArrayPath); +} + /** - * Validates whole form data or a specific field using a Valibot or Standard-Schema compatible object. + * Validates whole form data or a specific field using a Valibot or + * Standard-Schema compatible object. */ export function standardSchemaValidator( schema: any, - fieldName?: string + fieldNameOrOptions?: string | SchemaValidatorOptions ): FieldValidatorFunction { + const { field, target } = normalizeOptions(fieldNameOrOptions); + const filterBy = target === 'field' ? undefined : field; + return (value: unknown, data: Properties) => { - const payload = fieldName ? { ...data, [fieldName]: value } : data; + const payload = buildPayload(data, value, field, target); const std = schema['~standard'] || schema; - if (typeof std.validate === 'function') { - const result = std.validate(payload); - if (result instanceof Promise) { - return result.then((res: any) => { - if (!res.issues || res.issues.length === 0) { - return undefined; - } - const matched = fieldName - ? res.issues.filter( - (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) - ) - : res.issues; - if (matched.length === 0) { - return undefined; - } - return matched.map((i: any) => i.message); - }); - } - if (!result.issues || result.issues.length === 0) { - return undefined; - } - const matched = fieldName - ? result.issues.filter( - (i: any) => Array.isArray(i.path) && i.path.includes(fieldName) - ) - : result.issues; - if (matched.length === 0) { - return undefined; - } - return matched.map((i: any) => i.message); + + if (typeof std.validate !== 'function') { + return undefined; } - return undefined; + + const result = std.validate(payload); + return result instanceof Promise + ? result.then((res: any) => standardResultToMessages(res, filterBy)) + : standardResultToMessages(result, filterBy); }; } diff --git a/packages/core/test/adaptersRealSchemas.test.ts b/packages/core/test/adaptersRealSchemas.test.ts new file mode 100644 index 0000000..b9764bb --- /dev/null +++ b/packages/core/test/adaptersRealSchemas.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import * as yup from 'yup'; +import { z } from 'zod'; +import { + standardSchemaValidator, + yupValidator, + zodValidator, +} from '../src/adapters'; +import type { FieldDescription } from '../src/types'; +import { validateFields, validateFieldsAsync } from '../src/validation'; + +describe('zodValidator with a real Zod schema', () => { + const schema = z.object({ + email: z.string().email('Invalid email address'), + }); + + it('reports field errors through the synchronous validateFields path', () => { + const fields: FieldDescription[] = [ + { name: 'email', type: 'text', validate: zodValidator(schema, 'email') }, + ]; + + const res = validateFields(fields, { email: 'not-an-email' }); + + expect(res.valid).toBe(false); + expect(res.errors.email).toEqual(['Invalid email address']); + }); + + it('returns messages synchronously rather than a Promise', () => { + const result = zodValidator(schema, 'email')('nope', { email: 'nope' }); + + expect(result).not.toBeInstanceOf(Promise); + expect(result).toEqual(['Invalid email address']); + }); + + it('accepts valid data', () => { + const result = zodValidator(schema, 'email')('a@b.com', { + email: 'a@b.com', + }); + + expect(result).toBeUndefined(); + }); + + it('validates the whole form object when no fieldName is given', () => { + const validator = zodValidator(schema); + + // `value` is the field's own value; the schema describes the whole form, + // so the form data must be what gets parsed. + const result = validator('not-an-email', { email: 'not-an-email' }); + + expect(result).toEqual(['Invalid email address']); + }); + + it('falls back to async parsing for schemas with async refinements', async () => { + const asyncSchema = z.object({ + name: z.string().refine(async (v) => v.length > 2, 'Name is too short'), + }); + const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: zodValidator(asyncSchema, 'name'), + }, + ]; + + const res = await validateFieldsAsync(fields, { name: 'x' }); + + expect(res.valid).toBe(false); + expect(res.errors.name).toEqual(['Name is too short']); + }); +}); + +describe('yupValidator with a real Yup schema', () => { + const schema = yup.object({ + age: yup.number().min(18, 'Must be at least 18'), + }); + + it('reports field errors through the synchronous validateFields path', () => { + const fields: FieldDescription[] = [ + { name: 'age', type: 'number', validate: yupValidator(schema, 'age') }, + ]; + + const res = validateFields(fields, { age: 15 }); + + expect(res.valid).toBe(false); + expect(res.errors.age).toEqual(['Must be at least 18']); + }); + + it('accepts valid data', () => { + const result = yupValidator(schema, 'age')(20, { age: 20 }); + + expect(result).toBeUndefined(); + }); + + it('validates the whole form object when no fieldName is given', () => { + const result = yupValidator(schema)(15, { age: 15 }); + + expect(result).toEqual(['Must be at least 18']); + }); + + it('does not surface Yup internal async errors as validation messages', async () => { + const asyncSchema = yup.object({ + name: yup + .string() + .test('async-check', 'Name is taken', async (v) => v === 'free'), + }); + + const result = await yupValidator(asyncSchema, 'name')('taken', { + name: 'taken', + }); + + expect(result).toEqual(['Name is taken']); + }); + + it('falls back to async validation through validateFieldsAsync', async () => { + const asyncSchema = yup.object({ + name: yup + .string() + .test('async-check', 'Name is taken', async (v) => v === 'free'), + }); + const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: yupValidator(asyncSchema, 'name'), + }, + ]; + + const res = await validateFieldsAsync(fields, { name: 'taken' }); + + expect(res.errors.name).toEqual(['Name is taken']); + }); +}); + +describe('field-level (scalar) schemas via target: "field"', () => { + it('parses the field value alone with a scalar Zod schema', () => { + const validator = zodValidator(z.string().email('Invalid email address'), { + target: 'field', + }); + + expect(validator('nope', { email: 'nope' })).toEqual([ + 'Invalid email address', + ]); + expect(validator('a@b.com', { email: 'a@b.com' })).toBeUndefined(); + }); + + it('parses the field value alone with a scalar Yup schema', () => { + const validator = yupValidator(yup.string().min(3, 'Too short'), { + target: 'field', + }); + + expect(validator('hi', { name: 'hi' })).toEqual(['Too short']); + expect(validator('hello', { name: 'hello' })).toBeUndefined(); + }); + + it('parses the field value alone with a scalar Standard Schema', () => { + const validator = standardSchemaValidator(z.string().min(2, 'Too short'), { + target: 'field', + }); + + expect(validator('x', { city: 'x' })).toEqual(['Too short']); + expect(validator('xy', { city: 'xy' })).toBeUndefined(); + }); + + it('still accepts a plain field name string for form schemas', () => { + const schema = z.object({ email: z.string().email('Invalid email') }); + + expect(zodValidator(schema, 'email')('bad', { email: 'bad' })).toEqual([ + 'Invalid email', + ]); + expect( + zodValidator(schema, { field: 'email' })('bad', { email: 'bad' }) + ).toEqual(['Invalid email']); + }); +}); + +describe('standardSchemaValidator with a real Zod schema', () => { + const schema = z.object({ + city: z.string().min(2, 'City is too short'), + }); + + it('reports field errors through the synchronous validateFields path', () => { + const fields: FieldDescription[] = [ + { + name: 'city', + type: 'text', + validate: standardSchemaValidator(schema, 'city'), + }, + ]; + + const res = validateFields(fields, { city: 'x' }); + + expect(res.valid).toBe(false); + expect(res.errors.city).toEqual(['City is too short']); + }); + + it('validates the whole form object when no fieldName is given', () => { + const result = standardSchemaValidator(schema)('x', { city: 'x' }); + + expect(result).toEqual(['City is too short']); + }); +}); diff --git a/packages/core/test/validation.test.ts b/packages/core/test/validation.test.ts index aee996d..fa02af6 100644 --- a/packages/core/test/validation.test.ts +++ b/packages/core/test/validation.test.ts @@ -206,7 +206,7 @@ describe('validateFieldsAsync', () => { }); describe('zodValidator and yupValidator', () => { - test('zodValidator validates field target or data object', () => { + test('zodValidator parses the value alone for a scalar schema', () => { const mockZod = { safeParse: (val: unknown) => { if (typeof val === 'string' && val.includes('@')) { @@ -220,12 +220,12 @@ describe('zodValidator and yupValidator', () => { }; }, }; - const validator = zodValidator(mockZod); + const validator = zodValidator(mockZod, { target: 'field' }); expect(validator('invalid', {})).toEqual(['Invalid email address']); expect(validator('user@test.com', {})).toBeUndefined(); }); - test('yupValidator validates field target or data object', () => { + test('yupValidator parses the value alone for a scalar schema', () => { const mockYup = { validateSync: (val: unknown) => { if (typeof val === 'string' && val.length >= 3) { @@ -234,7 +234,7 @@ describe('zodValidator and yupValidator', () => { throw { message: 'Must be at least 3 chars' }; }, }; - const validator = yupValidator(mockYup); + const validator = yupValidator(mockYup, { target: 'field' }); expect(validator('hi', {})).toEqual(['Must be at least 3 chars']); expect(validator('hello', {})).toBeUndefined(); }); From c0505640461bf7590d688c3f8d6f62ce4aa6b34f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:50:08 +0700 Subject: [PATCH 05/20] feat: give useDynamicForm the same surface in every framework Why: The three form-state APIs had drifted. React was missing isSubmitting and isSubmitted, which Vue and Angular both exposed, so a React consumer had no way to disable a submit button while a submission was in flight. Angular's handleSubmit executed immediately instead of returning a handler like React and Vue, and never called preventDefault - so it could not be bound to a native form submit at all. What: - React: add isSubmitting / isSubmitted, set around handleSubmit in a try/finally so a throwing onValid still clears the flag, and reset both. - Angular: handleSubmit(onValid, onInvalid) now returns an async handler that calls preventDefault, matching React and Vue. These APIs are unreleased (published angular is 1.4.0 without the store), so no published consumer is affected. How to test: npm run test --workspace=@dynamic-field-kit/react npm run test --workspace=@dynamic-field-kit/angular --- .../angular/src/lib/dynamic-form.store.ts | 36 +-- .../angular/test/dynamicFormStore.spec.ts | 127 ++++++++++- packages/react/src/useDynamicForm.ts | 26 ++- packages/react/test/useDynamicForm.test.tsx | 210 ++++++++++++++++++ packages/vue/test/useDynamicForm.test.ts | 179 +++++++++++++++ 5 files changed, 554 insertions(+), 24 deletions(-) create mode 100644 packages/react/test/useDynamicForm.test.tsx create mode 100644 packages/vue/test/useDynamicForm.test.ts diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts index 7e1b7bc..8e9a769 100644 --- a/packages/angular/src/lib/dynamic-form.store.ts +++ b/packages/angular/src/lib/dynamic-form.store.ts @@ -72,23 +72,33 @@ export function createDynamicFormStore(options: DynamicFormOptions) { isSubmitted.set(false); } - async function handleSubmit( + /** + * Returns a submit handler, mirroring the React and Vue `useDynamicForm` + * hooks. Bind it once and use it as the `(ngSubmit)` handler: + * `onSubmit = this.store.handleSubmit(data => ...)`. + */ + function handleSubmit( onValid: (data: Properties) => void | Promise, onInvalid?: (errors: Record) => void ) { - isSubmitting.set(true); - try { - const res = validateFields(fields, data()); - errors.set(res.errors); - isSubmitted.set(true); - if (res.valid) { - await onValid(data()); - } else if (onInvalid) { - onInvalid(res.errors); + return async (e?: Event) => { + if (e && typeof e.preventDefault === 'function') { + e.preventDefault(); } - } finally { - isSubmitting.set(false); - } + isSubmitting.set(true); + try { + const res = validateFields(fields, data()); + errors.set(res.errors); + isSubmitted.set(true); + if (res.valid) { + await onValid(data()); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + isSubmitting.set(false); + } + }; } return { diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts index 5fe4c6e..8916198 100644 --- a/packages/angular/test/dynamicFormStore.spec.ts +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -1,13 +1,18 @@ import { FieldDescription } from '@dynamic-field-kit/core'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createDynamicFormStore } from '../src/lib/dynamic-form.store'; +const fields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + required: true, + validate: (v) => (v ? undefined : 'Username is required'), + }, +]; + describe('Angular Signal DynamicFormStore', () => { it('initializes signal data and handles changes', () => { - const fields: FieldDescription[] = [ - { name: 'username', type: 'text', required: true }, - ]; - const store = createDynamicFormStore({ fields, initialValues: { username: 'john_doe' }, @@ -24,4 +29,116 @@ describe('Angular Signal DynamicFormStore', () => { expect(store.data().username).toBe('john_doe'); expect(store.isDirty()).toBe(false); }); + + it('returns a submit handler, matching the React and Vue hooks', async () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + const onValid = vi.fn(); + + const submit = store.handleSubmit(onValid); + expect(typeof submit).toBe('function'); + + await submit(); + + expect(onValid).toHaveBeenCalledWith({ username: 'john_doe' }); + expect(store.isSubmitted()).toBe(true); + expect(store.isSubmitting()).toBe(false); + }); + + it('calls preventDefault on the submitted event', async () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + const preventDefault = vi.fn(); + + await store.handleSubmit(vi.fn())({ preventDefault } as unknown as Event); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('routes validation failures to onInvalid', async () => { + const store = createDynamicFormStore({ fields }); + const onValid = vi.fn(); + const onInvalid = vi.fn(); + + await store.handleSubmit(onValid, onInvalid)(); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ + username: ['Username is required'], + }); + expect(store.isValid()).toBe(false); + }); + + it('clears isSubmitting when the submit handler throws', async () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + + await expect( + store.handleSubmit(() => { + throw new Error('boom'); + })() + ).rejects.toThrow('boom'); + + expect(store.isSubmitting()).toBe(false); + }); + + it('validates on blur and marks the field touched', () => { + const store = createDynamicFormStore({ fields }); + + store.handleBlur('username'); + + expect(store.touched().username).toBe(true); + expect(store.errors().username).toEqual(['Username is required']); + }); + + it('skips blur validation when validateOnBlur is false', () => { + const store = createDynamicFormStore({ fields, validateOnBlur: false }); + + store.handleBlur('username'); + + expect(store.touched().username).toBe(true); + expect(store.errors()).toEqual({}); + }); + + it('validates on change when validateOnChange is true', () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + validateOnChange: true, + }); + + store.setFieldValue('username', ''); + + expect(store.errors().username).toEqual(['Username is required']); + }); + + it('supports setFieldTouched and imperative validate', () => { + const store = createDynamicFormStore({ fields }); + + store.setFieldTouched('username'); + expect(store.touched().username).toBe(true); + store.setFieldTouched('username', false); + expect(store.touched().username).toBe(false); + + expect(store.validate()).toBe(false); + expect(store.errors().username).toEqual(['Username is required']); + }); + + it('resets to explicitly supplied values', () => { + const store = createDynamicFormStore({ + fields, + initialValues: { username: 'john_doe' }, + }); + + store.reset({ username: 'ada' }); + + expect(store.data().username).toBe('ada'); + expect(store.isSubmitted()).toBe(false); + }); }); diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts index 72a58a1..bdfe42c 100644 --- a/packages/react/src/useDynamicForm.ts +++ b/packages/react/src/useDynamicForm.ts @@ -18,6 +18,8 @@ export interface UseDynamicFormResult { errors: Record; isValid: boolean; isDirty: boolean; + isSubmitting: boolean; + isSubmitted: boolean; touched: Record; setData: React.Dispatch>; setFieldValue: (name: string, value: unknown) => void; @@ -44,6 +46,8 @@ export function useDynamicForm({ const [errors, setErrors] = useState>({}); const [isDirty, setIsDirty] = useState(false); const [touched, setTouched] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); const validate = useCallback(() => { const res = validateFields(fields, data); @@ -95,6 +99,8 @@ export function useDynamicForm({ setErrors({}); setIsDirty(false); setTouched({}); + setIsSubmitting(false); + setIsSubmitted(false); }, [fields, initialValues] ); @@ -108,12 +114,18 @@ export function useDynamicForm({ if (e && typeof e.preventDefault === 'function') { e.preventDefault(); } - const res = validateFields(fields, data); - setErrors(res.errors); - if (res.valid) { - await onValid(data); - } else if (onInvalid) { - onInvalid(res.errors); + setIsSubmitting(true); + try { + const res = validateFields(fields, data); + setErrors(res.errors); + setIsSubmitted(true); + if (res.valid) { + await onValid(data); + } else if (onInvalid) { + onInvalid(res.errors); + } + } finally { + setIsSubmitting(false); } }, [fields, data] @@ -126,6 +138,8 @@ export function useDynamicForm({ errors, isValid, isDirty, + isSubmitting, + isSubmitted, touched, setData, setFieldValue, diff --git a/packages/react/test/useDynamicForm.test.tsx b/packages/react/test/useDynamicForm.test.tsx new file mode 100644 index 0000000..dae6c9e --- /dev/null +++ b/packages/react/test/useDynamicForm.test.tsx @@ -0,0 +1,210 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { FieldDescription } from '../src'; +import { useDynamicForm } from '../src'; + +const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: (v) => (v ? undefined : 'Name is required'), + }, + { name: 'nickname', type: 'text' }, +]; + +describe('useDynamicForm submission state', () => { + it('starts out not submitting and not submitted', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.isSubmitted).toBe(false); + }); + + it('flags isSubmitting while the submit handler is in flight', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + let submission!: Promise; + act(() => { + submission = result.current.handleSubmit(() => pending)(); + }); + + await waitFor(() => expect(result.current.isSubmitting).toBe(true)); + + await act(async () => { + release(); + await submission; + }); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.isSubmitted).toBe(true); + }); + + it('clears isSubmitting when the submit handler throws', async () => { + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + await act(async () => { + await expect( + result.current.handleSubmit(() => { + throw new Error('boom'); + })() + ).rejects.toThrow('boom'); + }); + + expect(result.current.isSubmitting).toBe(false); + }); + + it('marks isSubmitted even when validation fails', async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const { result } = renderHook(() => useDynamicForm({ fields })); + + await act(async () => { + await result.current.handleSubmit(onValid, onInvalid)(); + }); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ name: ['Name is required'] }); + expect(result.current.isSubmitted).toBe(true); + expect(result.current.isSubmitting).toBe(false); + }); + + it('resets submission state', async () => { + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + await act(async () => { + await result.current.handleSubmit(vi.fn())(); + }); + expect(result.current.isSubmitted).toBe(true); + + act(() => result.current.reset()); + + expect(result.current.isSubmitted).toBe(false); + expect(result.current.isSubmitting).toBe(false); + }); +}); + +describe('useDynamicForm behaviour', () => { + it('calls preventDefault on the submitted event', async () => { + const preventDefault = vi.fn(); + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + await act(async () => { + await result.current.handleSubmit(vi.fn())({ + preventDefault, + } as unknown as React.FormEvent); + }); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('tracks dirty state and touched fields', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + expect(result.current.isDirty).toBe(false); + + act(() => result.current.setFieldValue('nickname', 'Al')); + expect(result.current.isDirty).toBe(true); + expect(result.current.data.nickname).toBe('Al'); + + act(() => result.current.setFieldTouched('nickname')); + expect(result.current.touched.nickname).toBe(true); + + act(() => result.current.setFieldTouched('nickname', false)); + expect(result.current.touched.nickname).toBe(false); + }); + + it('validates on blur by default', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + act(() => result.current.handleBlur('name')); + + expect(result.current.errors.name).toEqual(['Name is required']); + expect(result.current.touched.name).toBe(true); + expect(result.current.isValid).toBe(false); + }); + + it('skips blur validation when validateOnBlur is false', () => { + const { result } = renderHook(() => + useDynamicForm({ fields, validateOnBlur: false }) + ); + + act(() => result.current.handleBlur('name')); + + expect(result.current.errors).toEqual({}); + expect(result.current.touched.name).toBe(true); + }); + + it('validates on change when validateOnChange is true', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + validateOnChange: true, + }) + ); + + act(() => result.current.setFieldValue('name', '')); + + expect(result.current.errors.name).toEqual(['Name is required']); + }); + + it('exposes an imperative validate()', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + let valid!: boolean; + act(() => { + valid = result.current.validate(); + }); + + expect(valid).toBe(false); + expect(result.current.errors.name).toEqual(['Name is required']); + }); + + it('applies computed values to the initial data', () => { + const computed: FieldDescription[] = [ + { name: 'first', type: 'text' }, + { + name: 'upper', + type: 'text', + computeValue: (d) => String(d.first ?? '').toUpperCase(), + }, + ]; + const { result } = renderHook(() => + useDynamicForm({ fields: computed, initialValues: { first: 'ada' } }) + ); + + expect(result.current.data.upper).toBe('ADA'); + }); + + it('resets to explicitly supplied values', () => { + const { result } = renderHook(() => + useDynamicForm({ fields, initialValues: { name: 'Alice' } }) + ); + + act(() => result.current.reset({ name: 'Grace' })); + + expect(result.current.data.name).toBe('Grace'); + expect(result.current.isDirty).toBe(false); + expect(result.current.errors).toEqual({}); + }); + + it('supports direct setData updates', () => { + const { result } = renderHook(() => useDynamicForm({ fields })); + + act(() => result.current.setData({ name: 'Direct' })); + + expect(result.current.data.name).toBe('Direct'); + }); +}); diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts new file mode 100644 index 0000000..12a323c --- /dev/null +++ b/packages/vue/test/useDynamicForm.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FieldDescription } from '../src'; +import { useDynamicForm } from '../src'; + +const fields: FieldDescription[] = [ + { + name: 'name', + type: 'text', + validate: (v) => (v ? undefined : 'Name is required'), + }, + { name: 'nickname', type: 'text' }, +]; + +describe('useDynamicForm submission state', () => { + it('starts out not submitting and not submitted', () => { + const form = useDynamicForm({ fields }); + + expect(form.isSubmitting.value).toBe(false); + expect(form.isSubmitted.value).toBe(false); + }); + + it('flags isSubmitting while the submit handler is in flight', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const form = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + }); + + const submission = form.handleSubmit(() => pending)(); + expect(form.isSubmitting.value).toBe(true); + + release(); + await submission; + + expect(form.isSubmitting.value).toBe(false); + expect(form.isSubmitted.value).toBe(true); + }); + + it('clears isSubmitting when the submit handler throws', async () => { + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + await expect( + form.handleSubmit(() => { + throw new Error('boom'); + })() + ).rejects.toThrow('boom'); + + expect(form.isSubmitting.value).toBe(false); + }); + + it('routes validation failures to onInvalid', async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const form = useDynamicForm({ fields }); + + await form.handleSubmit(onValid, onInvalid)(); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ name: ['Name is required'] }); + expect(form.isSubmitted.value).toBe(true); + }); + + it('calls preventDefault on the submitted event', async () => { + const preventDefault = vi.fn(); + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + await form.handleSubmit(vi.fn())({ preventDefault } as unknown as Event); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it('resets submission state', async () => { + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + await form.handleSubmit(vi.fn())(); + expect(form.isSubmitted.value).toBe(true); + + form.reset(); + + expect(form.isSubmitted.value).toBe(false); + expect(form.isSubmitting.value).toBe(false); + }); +}); + +describe('useDynamicForm behaviour', () => { + it('tracks dirty state and touched fields', () => { + const form = useDynamicForm({ fields }); + + expect(form.isDirty.value).toBe(false); + + form.setFieldValue('nickname', 'Al'); + expect(form.isDirty.value).toBe(true); + expect(form.data.value.nickname).toBe('Al'); + + form.setFieldTouched('nickname'); + expect(form.touched.value.nickname).toBe(true); + + form.setFieldTouched('nickname', false); + expect(form.touched.value.nickname).toBe(false); + }); + + it('validates on blur by default', () => { + const form = useDynamicForm({ fields }); + + form.handleBlur('name'); + + expect(form.errors.value.name).toEqual(['Name is required']); + expect(form.touched.value.name).toBe(true); + expect(form.isValid.value).toBe(false); + }); + + it('skips blur validation when validateOnBlur is false', () => { + const form = useDynamicForm({ fields, validateOnBlur: false }); + + form.handleBlur('name'); + + expect(form.errors.value).toEqual({}); + expect(form.touched.value.name).toBe(true); + }); + + it('validates on change when validateOnChange is true', () => { + const form = useDynamicForm({ + fields, + initialValues: { name: 'Alice' }, + validateOnChange: true, + }); + + form.setFieldValue('name', ''); + + expect(form.errors.value.name).toEqual(['Name is required']); + }); + + it('exposes an imperative validate()', () => { + const form = useDynamicForm({ fields }); + + expect(form.validate()).toBe(false); + expect(form.errors.value.name).toEqual(['Name is required']); + }); + + it('applies computed values to the initial data', () => { + const computed: FieldDescription[] = [ + { name: 'first', type: 'text' }, + { + name: 'upper', + type: 'text', + computeValue: (d) => String(d.first ?? '').toUpperCase(), + }, + ]; + + const form = useDynamicForm({ + fields: computed, + initialValues: { first: 'ada' }, + }); + + expect(form.data.value.upper).toBe('ADA'); + }); + + it('resets to explicitly supplied values', () => { + const form = useDynamicForm({ fields, initialValues: { name: 'Alice' } }); + + form.reset({ name: 'Grace' }); + + expect(form.data.value.name).toBe('Grace'); + expect(form.isDirty.value).toBe(false); + expect(form.errors.value).toEqual({}); + }); + + it('handles a whole-form change', () => { + const form = useDynamicForm({ fields }); + + form.handleChange({ name: 'Bulk', nickname: 'B' }); + + expect(form.data.value).toEqual({ name: 'Bulk', nickname: 'B' }); + expect(form.isDirty.value).toBe(true); + }); +}); From 3fe46fb3c249c68a44b8c27a8a832070008955b1 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:50:28 +0700 Subject: [PATCH 06/20] test: cover DevTools, extended renderers and group helpers Why: The new feature work landed largely untested and pushed every package below its coverage floor, which is a hard CI gate: core 68/63/79/68, react functions 77.7, vue 84/74/72/84, angular 83/71/85/83. On develop core sat at 88/85/100/88, so this was a regression introduced by the feature branch. What: Tests for the code that shipped without any - group array helpers (move/swap/insert plus focusFirstInvalidField under jsdom), wizard index clamping and the empty-steps case, the DevTools overlay across all four tabs in all three frameworks, and the extended HTML5 renderers (date/time/ datetime-local/switch/file, option id/name fallbacks, range). Coverage is now core 89.95/85.04/96.96/90.33, react 97.34/86.95/95.65/97.34, vue 98.87/87.01/94.02/98.87, angular 91.30/75.56/92.40/91.22 - all above their floors. Suite grew from 249 to 394 tests. How to test: npm run test --workspace=@dynamic-field-kit/ -- --coverage --- .../angular/test/DynamicFormDevTools.spec.ts | 100 +++++++++ packages/core/test/fieldGroupArray.test.ts | 96 +++++++++ .../core/test/focusFirstInvalidField.test.ts | 57 +++++ packages/core/test/wizard.test.ts | 34 +++ .../react/test/DynamicFormDevTools.test.tsx | 120 +++++++++++ .../react/test/defaultRenderersExtra.test.tsx | 174 ++++++++++++++++ packages/vue/test/DynamicFormDevTools.test.ts | 117 +++++++++++ .../vue/test/defaultRenderersExtra.test.ts | 195 ++++++++++++++++++ 8 files changed, 893 insertions(+) create mode 100644 packages/angular/test/DynamicFormDevTools.spec.ts create mode 100644 packages/core/test/fieldGroupArray.test.ts create mode 100644 packages/core/test/focusFirstInvalidField.test.ts create mode 100644 packages/react/test/DynamicFormDevTools.test.tsx create mode 100644 packages/react/test/defaultRenderersExtra.test.tsx create mode 100644 packages/vue/test/DynamicFormDevTools.test.ts create mode 100644 packages/vue/test/defaultRenderersExtra.test.ts diff --git a/packages/angular/test/DynamicFormDevTools.spec.ts b/packages/angular/test/DynamicFormDevTools.spec.ts new file mode 100644 index 0000000..751969e --- /dev/null +++ b/packages/angular/test/DynamicFormDevTools.spec.ts @@ -0,0 +1,100 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FieldDescription } from '@dynamic-field-kit/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { DynamicFormDevToolsComponent } from '../src/components/DynamicFormDevTools'; + +function text(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent || ''; +} + +function buttons( + fixture: ComponentFixture +): HTMLButtonElement[] { + return Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('button') + ); +} + +function open(fixture: ComponentFixture) { + buttons(fixture)[0].click(); + fixture.detectChanges(); +} + +function clickTab( + fixture: ComponentFixture, + label: string +) { + const tab = buttons(fixture).find((b) => + (b.textContent || '').trim().toLowerCase().startsWith(label) + ); + tab?.click(); + fixture.detectChanges(); +} + +describe('DynamicFormDevToolsComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicFormDevToolsComponent); + fixture.detectChanges(); + }); + + it('renders collapsed by default', () => { + expect(text(fixture)).toContain('🔍 DevTools'); + expect(text(fixture)).not.toContain('🛠️ Form DevTools'); + }); + + it('opens the overlay', () => { + open(fixture); + + expect(text(fixture)).toContain('🛠️ Form DevTools'); + }); + + it('shows form data on the data tab', () => { + fixture.componentInstance.data = { email: 'a@b.com' }; + open(fixture); + + expect(text(fixture)).toContain('a@b.com'); + }); + + it('shows errors on the errors tab', () => { + fixture.componentInstance.errors = { email: ['Invalid email'] }; + open(fixture); + clickTab(fixture, 'errors'); + + expect(text(fixture)).toContain('Invalid email'); + }); + + it('shows dirty and touched state on the meta tab', () => { + fixture.componentInstance.isDirty = true; + fixture.componentInstance.touched = { email: true }; + open(fixture); + clickTab(fixture, 'meta'); + + expect(text(fixture)).toContain('isDirty: true'); + expect(text(fixture)).toContain('email'); + }); + + it('lists field descriptions on the fields tab', () => { + const fields: FieldDescription[] = [{ name: 'email', type: 'text' }]; + fixture.componentInstance.fields = fields; + open(fixture); + clickTab(fixture, 'fields'); + + expect(text(fixture)).toContain('email'); + expect(text(fixture)).toContain('type: text'); + }); + + it('closes the overlay again', () => { + open(fixture); + + const close = buttons(fixture).find( + (b) => (b.textContent || '').trim() === '✕' + ); + close?.click(); + fixture.detectChanges(); + + expect(text(fixture)).not.toContain('🛠️ Form DevTools'); + expect(text(fixture)).toContain('🔍 DevTools'); + }); +}); diff --git a/packages/core/test/fieldGroupArray.test.ts b/packages/core/test/fieldGroupArray.test.ts new file mode 100644 index 0000000..0420334 --- /dev/null +++ b/packages/core/test/fieldGroupArray.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'vitest'; +import { + insertGroupItem, + moveGroupItem, + swapGroupItems, +} from '../src/fieldGroup'; + +describe('moveGroupItem', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + test('moves an item forward', () => { + expect(moveGroupItem(items, 0, 2)).toEqual([ + { id: 'b' }, + { id: 'c' }, + { id: 'a' }, + ]); + }); + + test('moves an item backward', () => { + expect(moveGroupItem(items, 2, 0)).toEqual([ + { id: 'c' }, + { id: 'a' }, + { id: 'b' }, + ]); + }); + + test('does not mutate the original array', () => { + moveGroupItem(items, 0, 2); + expect(items).toEqual([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + }); + + test.each([ + ['negative from', -1, 1], + ['from past the end', 3, 1], + ['negative to', 0, -1], + ['to past the end', 0, 3], + ])('returns the array unchanged for %s', (_label, from, to) => { + expect(moveGroupItem(items, from, to)).toBe(items); + }); +}); + +describe('swapGroupItems', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + + test('swaps two items', () => { + expect(swapGroupItems(items, 0, 2)).toEqual([ + { id: 'c' }, + { id: 'b' }, + { id: 'a' }, + ]); + }); + + test('does not mutate the original array', () => { + swapGroupItems(items, 0, 2); + expect(items).toEqual([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + }); + + test.each([ + ['negative index a', -1, 1], + ['index a past the end', 3, 1], + ['negative index b', 0, -1], + ['index b past the end', 0, 3], + ])('returns the array unchanged for %s', (_label, a, b) => { + expect(swapGroupItems(items, a, b)).toBe(items); + }); +}); + +describe('insertGroupItem', () => { + const items = [{ id: 'a' }, { id: 'b' }]; + + test('inserts at the given index', () => { + expect(insertGroupItem(items, 1, { id: 'x' })).toEqual([ + { id: 'a' }, + { id: 'x' }, + { id: 'b' }, + ]); + }); + + test('defaults to an empty item', () => { + expect(insertGroupItem(items, 0)).toEqual([{}, { id: 'a' }, { id: 'b' }]); + }); + + test('clamps a negative index to the start', () => { + expect(insertGroupItem(items, -5, { id: 'x' })[0]).toEqual({ id: 'x' }); + }); + + test('clamps an index past the end to the end', () => { + const result = insertGroupItem(items, 99, { id: 'x' }); + expect(result[result.length - 1]).toEqual({ id: 'x' }); + }); + + test('does not mutate the original array', () => { + insertGroupItem(items, 1, { id: 'x' }); + expect(items).toEqual([{ id: 'a' }, { id: 'b' }]); + }); +}); diff --git a/packages/core/test/focusFirstInvalidField.test.ts b/packages/core/test/focusFirstInvalidField.test.ts new file mode 100644 index 0000000..a9896a9 --- /dev/null +++ b/packages/core/test/focusFirstInvalidField.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { focusFirstInvalidField } from '../src/fieldGroup'; + +describe('focusFirstInvalidField', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('focuses the first element flagged aria-invalid', () => { + document.body.innerHTML = ` + + + + `; + + expect(focusFirstInvalidField()).toBe(true); + expect(document.activeElement?.id).toBe('bad'); + }); + + test('searches only inside the given container', () => { + document.body.innerHTML = ` +
+
+ `; + const container = document.getElementById('inside') as HTMLElement; + + expect(focusFirstInvalidField(container)).toBe(true); + expect(document.activeElement?.id).toBe('insideBad'); + }); + + test('returns false when nothing is invalid', () => { + document.body.innerHTML = ''; + + expect(focusFirstInvalidField()).toBe(false); + }); + + test('scrolls the focused field into view when supported', () => { + document.body.innerHTML = ''; + const field = document.getElementById('bad') as HTMLElement; + field.scrollIntoView = vi.fn(); + + focusFirstInvalidField(); + + expect(field.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + + test('falls back to document.body when given null', () => { + document.body.innerHTML = ''; + + expect(focusFirstInvalidField(null)).toBe(true); + expect(document.activeElement?.id).toBe('bad'); + }); +}); diff --git a/packages/core/test/wizard.test.ts b/packages/core/test/wizard.test.ts index 52135dc..a44319d 100644 --- a/packages/core/test/wizard.test.ts +++ b/packages/core/test/wizard.test.ts @@ -62,4 +62,38 @@ describe('Wizard Core Module', () => { expect(canGoNext(state1)).toBe(false); expect(canGoPrev(state1)).toBe(true); }); + + it('defaults to the first step', () => { + const state = createWizardState(steps); + expect(state.currentStepIndex).toBe(0); + expect(state.currentStep.id).toBe('step1'); + expect(state.completedSteps).toEqual([]); + }); + + it('clamps an initial index past the last step', () => { + const state = createWizardState(steps, 99); + expect(state.currentStepIndex).toBe(1); + expect(state.isLastStep).toBe(true); + expect(state.isFirstStep).toBe(false); + }); + + it('clamps a negative initial index to the first step', () => { + const state = createWizardState(steps, -5); + expect(state.currentStepIndex).toBe(0); + expect(state.isFirstStep).toBe(true); + }); + + it('stays usable when there are no steps', () => { + const state = createWizardState([], 0); + expect(state.totalSteps).toBe(0); + expect(state.currentStep).toEqual({ id: '', title: '', fields: [] }); + expect(canGoNext(state)).toBe(false); + expect(canGoPrev(state)).toBe(false); + }); + + it('reports a step with no fields as valid', () => { + expect(validateStep({ id: 's', title: 'S', fields: [] }, {}).valid).toBe( + true + ); + }); }); diff --git a/packages/react/test/DynamicFormDevTools.test.tsx b/packages/react/test/DynamicFormDevTools.test.tsx new file mode 100644 index 0000000..a00373f --- /dev/null +++ b/packages/react/test/DynamicFormDevTools.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import type { FieldDescription } from '../src'; +import { DynamicFormDevTools } from '../src'; + +function open() { + fireEvent.click(screen.getByText('🔍 DevTools')); +} + +describe('DynamicFormDevTools', () => { + it('renders collapsed with no error badge when there are no errors', () => { + render(); + + expect(screen.getByText('🔍 DevTools')).toBeInTheDocument(); + expect(screen.queryByText('🛠️ Form DevTools')).not.toBeInTheDocument(); + }); + + it('shows the error count on the collapsed badge', () => { + render( + + ); + + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('shows form data on the data tab', () => { + render(); + open(); + + expect(screen.getByText(/a@b\.com/)).toBeInTheDocument(); + }); + + it('lists errors on the errors tab', () => { + render( + + ); + open(); + fireEvent.click(screen.getByRole('button', { name: /errors/i })); + + expect(screen.getByText('email:')).toBeInTheDocument(); + expect(screen.getByText('Invalid email')).toBeInTheDocument(); + expect(screen.getByText('Too short')).toBeInTheDocument(); + }); + + it('reports a clean form on the errors tab', () => { + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /errors/i })); + + expect(screen.getByText('✓ No validation errors')).toBeInTheDocument(); + }); + + it('shows dirty and touched state on the meta tab', () => { + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /meta/i })); + + expect(screen.getByText('isDirty:')).toBeInTheDocument(); + expect(screen.getByText('true')).toBeInTheDocument(); + expect(screen.getByText(/"email": true/)).toBeInTheDocument(); + }); + + it('lists field descriptions on the fields tab', () => { + const fields: FieldDescription[] = [ + { name: 'email', type: 'text', required: true }, + ]; + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /fields/i })); + + expect(screen.getByText('email')).toBeInTheDocument(); + expect( + screen.getByText(/type: text \| required: true/) + ).toBeInTheDocument(); + }); + + it('explains when no field descriptions were passed', () => { + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: /fields/i })); + + expect( + screen.getByText('No field descriptions passed') + ).toBeInTheDocument(); + }); + + it('shows the error count in the errors tab label', () => { + render(); + open(); + + expect( + screen.getByRole('button', { name: /errors \(1\)/i }) + ).toBeInTheDocument(); + }); + + it('closes the overlay again', () => { + render(); + open(); + + fireEvent.click(screen.getByText('✕')); + + expect(screen.queryByText('🛠️ Form DevTools')).not.toBeInTheDocument(); + expect(screen.getByText('🔍 DevTools')).toBeInTheDocument(); + }); + + it('anchors to the bottom left when asked', () => { + const { container } = render( + + ); + + expect(container.querySelector('button')).toHaveStyle({ left: '16px' }); + }); +}); diff --git a/packages/react/test/defaultRenderersExtra.test.tsx b/packages/react/test/defaultRenderersExtra.test.tsx new file mode 100644 index 0000000..3292f7f --- /dev/null +++ b/packages/react/test/defaultRenderersExtra.test.tsx @@ -0,0 +1,174 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + DefaultDateRenderer, + DefaultDateTimeLocalRenderer, + DefaultFileRenderer, + DefaultRadioRenderer, + DefaultRangeRenderer, + DefaultSelectRenderer, + DefaultSwitchRenderer, + DefaultTimeRenderer, + defaultRenderersMap, + getDefaultRenderer, +} from '../src/defaultRenderers'; + +describe('date/time renderers', () => { + it.each([ + ['date', DefaultDateRenderer], + ['time', DefaultTimeRenderer], + ['datetime-local', DefaultDateTimeLocalRenderer], + ])('renders an input of type %s', (type, Renderer) => { + const { container } = render(); + + expect(container.querySelector('input')).toHaveAttribute('type', type); + }); + + it('emits the raw string value on change', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + fireEvent.change(container.querySelector('input')!, { + target: { value: '10:30' }, + }); + + expect(onValueChange).toHaveBeenCalledWith('10:30'); + }); +}); + +describe('DefaultSwitchRenderer', () => { + it('renders a checkbox reflecting the value', () => { + const { container } = render(); + + const input = container.querySelector('input')!; + expect(input).toHaveAttribute('type', 'checkbox'); + expect(input).toBeChecked(); + }); + + it('emits the checked state', () => { + const onValueChange = vi.fn(); + const { container } = render( + + ); + + fireEvent.click(container.querySelector('input')!); + + expect(onValueChange).toHaveBeenCalledWith(true); + }); +}); + +describe('DefaultFileRenderer', () => { + function fileInput(props: Record = {}) { + const { container } = render(); + return container.querySelector('input')!; + } + + it('emits a single File by default', () => { + const onValueChange = vi.fn(); + const input = fileInput({ onValueChange }); + const file = new File(['x'], 'a.txt', { type: 'text/plain' }); + + fireEvent.change(input, { target: { files: [file] } }); + + expect(onValueChange).toHaveBeenCalledWith(file); + }); + + it('emits an array when multiple is set', () => { + const onValueChange = vi.fn(); + const input = fileInput({ onValueChange, multiple: true }); + const a = new File(['a'], 'a.txt'); + const b = new File(['b'], 'b.txt'); + + fireEvent.change(input, { target: { files: [a, b] } }); + + expect(onValueChange).toHaveBeenCalledWith([a, b]); + }); + + it('emits null when a single selection is cleared', () => { + const onValueChange = vi.fn(); + const input = fileInput({ onValueChange }); + + fireEvent.change(input, { target: { files: [] } }); + + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it('forwards accept and multiple', () => { + const input = fileInput({ accept: '.png', multiple: true }); + + expect(input).toHaveAttribute('accept', '.png'); + expect(input).toHaveAttribute('multiple'); + }); +}); + +describe('option-shaped fallbacks', () => { + it('falls back to id/name when a select option has no value/label', () => { + render( + + ); + + expect(screen.getByRole('option', { name: 'One' })).toHaveValue('1'); + }); + + it('falls back to id/name when a radio option has no value/label', () => { + render( + + ); + + expect(screen.getByLabelText('One')).toBeChecked(); + }); + + it('treats a readOnly select as disabled', () => { + render(); + + expect(screen.getByRole('combobox')).toBeDisabled(); + }); +}); + +describe('DefaultRangeRenderer', () => { + it('falls back to min when no value is set', () => { + render(); + + expect(screen.getByRole('slider')).toHaveValue('5'); + }); + + it('emits a number on change', () => { + const onValueChange = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByRole('slider'), { target: { value: '7' } }); + + expect(onValueChange).toHaveBeenCalledWith(7); + }); +}); + +describe('getDefaultRenderer', () => { + it('resolves every registered type', () => { + for (const type of Object.keys(defaultRenderersMap)) { + expect(getDefaultRenderer(type)).toBe(defaultRenderersMap[type]); + } + }); + + it('returns undefined for an unknown type', () => { + expect(getDefaultRenderer('nope')).toBeUndefined(); + }); +}); diff --git a/packages/vue/test/DynamicFormDevTools.test.ts b/packages/vue/test/DynamicFormDevTools.test.ts new file mode 100644 index 0000000..41c7448 --- /dev/null +++ b/packages/vue/test/DynamicFormDevTools.test.ts @@ -0,0 +1,117 @@ +import { mount, VueWrapper } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import type { FieldDescription } from '../src'; +import { DynamicFormDevTools } from '../src'; + +function mountDevTools(props: Record = {}) { + return mount(DynamicFormDevTools, { props: { data: {}, ...props } }); +} + +async function open(wrapper: VueWrapper) { + await wrapper.find('button').trigger('click'); +} + +async function clickTab(wrapper: VueWrapper, label: string) { + const tab = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().startsWith(label)); + await tab!.trigger('click'); +} + +describe('DynamicFormDevTools (Vue)', () => { + it('renders collapsed without an error badge', () => { + const wrapper = mountDevTools(); + + expect(wrapper.text()).toContain('🔍 DevTools'); + expect(wrapper.text()).not.toContain('🛠️ Form DevTools'); + }); + + it('shows the error count on the collapsed badge', () => { + const wrapper = mountDevTools({ + errors: { name: ['required'], email: ['invalid'] }, + }); + + expect(wrapper.text()).toContain('2'); + }); + + it('shows form data on the data tab', async () => { + const wrapper = mountDevTools({ data: { email: 'a@b.com' } }); + await open(wrapper); + + expect(wrapper.text()).toContain('a@b.com'); + }); + + it('lists errors on the errors tab', async () => { + const wrapper = mountDevTools({ + errors: { email: ['Invalid email', 'Too short'] }, + }); + await open(wrapper); + await clickTab(wrapper, 'errors'); + + expect(wrapper.text()).toContain('email:'); + expect(wrapper.text()).toContain('Invalid email'); + expect(wrapper.text()).toContain('Too short'); + }); + + it('reports a clean form on the errors tab', async () => { + const wrapper = mountDevTools(); + await open(wrapper); + await clickTab(wrapper, 'errors'); + + expect(wrapper.text()).toContain('✓ No validation errors'); + }); + + it('shows dirty and touched state on the meta tab', async () => { + const wrapper = mountDevTools({ isDirty: true, touched: { email: true } }); + await open(wrapper); + await clickTab(wrapper, 'meta'); + + expect(wrapper.text()).toContain('isDirty:'); + expect(wrapper.text()).toContain('true'); + expect(wrapper.text()).toContain('"email": true'); + }); + + it('lists field descriptions on the fields tab', async () => { + const fields: FieldDescription[] = [ + { name: 'email', type: 'text', required: true }, + ]; + const wrapper = mountDevTools({ fields }); + await open(wrapper); + await clickTab(wrapper, 'fields'); + + expect(wrapper.text()).toContain('email'); + expect(wrapper.text()).toContain('type: text | required: true'); + }); + + it('explains when no field descriptions were passed', async () => { + const wrapper = mountDevTools(); + await open(wrapper); + await clickTab(wrapper, 'fields'); + + expect(wrapper.text()).toContain('No field descriptions passed'); + }); + + it('shows the error count in the errors tab label', async () => { + const wrapper = mountDevTools({ errors: { name: ['required'] } }); + await open(wrapper); + + expect(wrapper.text()).toContain('errors (1)'); + }); + + it('closes the overlay again', async () => { + const wrapper = mountDevTools(); + await open(wrapper); + + const closeButton = wrapper.findAll('button').find((b) => b.text() === '✕'); + await closeButton!.trigger('click'); + + expect(wrapper.text()).not.toContain('🛠️ Form DevTools'); + expect(wrapper.text()).toContain('🔍 DevTools'); + }); + + it('anchors to the bottom left when asked', () => { + const wrapper = mountDevTools({ position: 'bottom-left' }); + + expect(wrapper.find('button').attributes('style')).toContain('left: 16px'); + }); +}); diff --git a/packages/vue/test/defaultRenderersExtra.test.ts b/packages/vue/test/defaultRenderersExtra.test.ts new file mode 100644 index 0000000..7d49024 --- /dev/null +++ b/packages/vue/test/defaultRenderersExtra.test.ts @@ -0,0 +1,195 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { + DefaultDateRenderer, + DefaultDateTimeLocalRenderer, + DefaultFileRenderer, + DefaultRadioRenderer, + DefaultRangeRenderer, + DefaultSelectRenderer, + DefaultSwitchRenderer, + DefaultTimeRenderer, + defaultRenderersMap, + getDefaultRenderer, +} from '../src/defaultRenderers'; + +describe('date/time renderers (Vue)', () => { + it.each([ + ['date', DefaultDateRenderer], + ['time', DefaultTimeRenderer], + ['datetime-local', DefaultDateTimeLocalRenderer], + ])('renders an input of type %s', (type, Renderer) => { + const wrapper = mount(Renderer as never, { props: { value: '' } }); + + expect(wrapper.find('input').attributes('type')).toBe(type); + }); + + it('emits the raw string value on input', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultTimeRenderer as never, { + props: { value: '', onValueChange }, + }); + + await wrapper.find('input').setValue('10:30'); + + expect(onValueChange).toHaveBeenCalledWith('10:30'); + }); +}); + +describe('DefaultSwitchRenderer (Vue)', () => { + it('renders a checkbox reflecting the value', () => { + const wrapper = mount(DefaultSwitchRenderer as never, { + props: { value: true }, + }); + + const input = wrapper.find('input'); + expect(input.attributes('type')).toBe('checkbox'); + expect((input.element as HTMLInputElement).checked).toBe(true); + }); + + it('emits the checked state', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultSwitchRenderer as never, { + props: { value: false, onValueChange }, + }); + + await wrapper.find('input').setValue(true); + + expect(onValueChange).toHaveBeenCalledWith(true); + }); +}); + +describe('DefaultFileRenderer (Vue)', () => { + function changeFiles(wrapper: ReturnType, files: File[]) { + const input = wrapper.find('input').element as HTMLInputElement; + Object.defineProperty(input, 'files', { value: files, writable: false }); + return wrapper.find('input').trigger('change'); + } + + it('emits a single File by default', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultFileRenderer as never, { + props: { onValueChange }, + }); + const file = new File(['x'], 'a.txt'); + + await changeFiles(wrapper, [file]); + + expect(onValueChange).toHaveBeenCalledWith(file); + }); + + it('emits an array when multiple is set', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultFileRenderer as never, { + props: { onValueChange, multiple: true }, + }); + const a = new File(['a'], 'a.txt'); + const b = new File(['b'], 'b.txt'); + + await changeFiles(wrapper, [a, b]); + + expect(onValueChange).toHaveBeenCalledWith([a, b]); + }); + + it('emits null when a single selection is cleared', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultFileRenderer as never, { + props: { onValueChange }, + }); + + await changeFiles(wrapper, []); + + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it('forwards accept and multiple', () => { + const wrapper = mount(DefaultFileRenderer as never, { + props: { accept: '.png', multiple: true }, + }); + + expect(wrapper.find('input').attributes('accept')).toBe('.png'); + expect(wrapper.find('input').attributes('multiple')).toBeDefined(); + }); +}); + +describe('option-shaped fallbacks (Vue)', () => { + it('falls back to id/name when a select option has no value/label', () => { + const wrapper = mount(DefaultSelectRenderer as never, { + props: { value: '1', options: [{ id: '1', name: 'One' }] }, + }); + + const option = wrapper.findAll('option')[1]; + expect(option.text()).toBe('One'); + expect(option.attributes('value')).toBe('1'); + }); + + it('falls back to id/name when a radio option has no value/label', () => { + const wrapper = mount(DefaultRadioRenderer as never, { + props: { value: '1', options: [{ id: '1', name: 'One' }], id: 'rad' }, + }); + + expect(wrapper.text()).toContain('One'); + expect( + (wrapper.find('input[type="radio"]').element as HTMLInputElement).checked + ).toBe(true); + }); + + it('emits the option value when a radio is picked', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultRadioRenderer as never, { + props: { + value: 'a', + options: [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + ], + onValueChange, + }, + }); + + await wrapper.findAll('input[type="radio"]')[1].trigger('change'); + + expect(onValueChange).toHaveBeenCalledWith('b'); + }); + + it('treats a readOnly select as disabled', () => { + const wrapper = mount(DefaultSelectRenderer as never, { + props: { value: '', options: [], readOnly: true }, + }); + + expect(wrapper.find('select').attributes('disabled')).toBeDefined(); + }); +}); + +describe('DefaultRangeRenderer (Vue)', () => { + it('falls back to min when no value is set', () => { + const wrapper = mount(DefaultRangeRenderer as never, { + props: { min: 5, max: 10 }, + }); + + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('5'); + }); + + it('emits a number on input', async () => { + const onValueChange = vi.fn(); + const wrapper = mount(DefaultRangeRenderer as never, { + props: { value: 5, min: 0, max: 10, onValueChange }, + }); + + await wrapper.find('input').setValue('7'); + + expect(onValueChange).toHaveBeenCalledWith(7); + }); +}); + +describe('getDefaultRenderer (Vue)', () => { + it('resolves every registered type', () => { + for (const type of Object.keys(defaultRenderersMap)) { + expect(getDefaultRenderer(type)).toBe(defaultRenderersMap[type]); + } + }); + + it('returns undefined for an unknown type', () => { + expect(getDefaultRenderer('nope')).toBeUndefined(); + }); +}); From 9f5cda33a1e58565f9974c7ead0777564c0b7095 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:50:42 +0700 Subject: [PATCH 07/20] docs: document adapter targets and add the missing changeset Why: The three feature commits on this branch added public API to all four packages without a changeset, so the work would have been merged and never released. The README also promised "Integrated zodValidator" without saying that adapters parse synchronously, which is the difference between a form that validates and one that silently passes. What: - Add a minor changeset covering core, react, vue and angular. - Document the schema adapter contract: form vs field target, the field-name shorthand, and when a schema forces validateFieldsAsync. - Note that all three frameworks share one hook surface. How to test: npx changeset status --- .changeset/lucky-pugs-shake.md | 22 ++++++++++++++++++++++ README.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-pugs-shake.md diff --git a/.changeset/lucky-pugs-shake.md b/.changeset/lucky-pugs-shake.md new file mode 100644 index 0000000..49e10ec --- /dev/null +++ b/.changeset/lucky-pugs-shake.md @@ -0,0 +1,22 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +Add form state hooks, schema adapters, wizard engine, DevTools and extended renderers. + +**Core** + +- `zodValidator`, `yupValidator`, `valibotValidator` / `standardSchemaValidator`. Adapters parse **synchronously** so their result is usable by the synchronous `validateFields`; schemas with async refinements or async `.test()` rules return a Promise and must be validated through `validateFieldsAsync`. +- Adapters take an explicit `{ target: 'form' | 'field' }` option. `'form'` (the default) parses the form data object; `'field'` parses a single scalar value. The field-name shorthand — `zodValidator(schema, 'email')` — is unchanged. +- Wizard engine: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`. +- Group array helpers: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, `focusFirstInvalidField`. + +**React / Vue / Angular** + +- `useDynamicForm` (React, Vue) and `createDynamicFormStore` (Angular Signals) now expose the same surface, including `isSubmitting` and `isSubmitted`. +- `handleSubmit(onValid, onInvalid)` returns a submit handler in every framework and calls `preventDefault` on the event it receives. +- Default HTML5 renderers for `radio`, `range`, `file`, `date`, `time`, `datetime-local` and `switch`. +- `DynamicFormDevTools` overlay for inspecting form data, errors, metadata and field descriptions. diff --git a/README.md b/README.md index 9e10f36..4ad797b 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,40 @@ A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, b ### 🚀 Enterprise Features (v1.4+) -- **Form State Hook / Composable / Signal Store**: `useDynamicForm` for React & Vue 3, `createDynamicFormStore` for Angular Signals. +- **Form State Hook / Composable / Signal Store**: `useDynamicForm` for React & Vue 3, `createDynamicFormStore` for Angular Signals. All three expose the same surface — including `isSubmitting` / `isSubmitted` — and `handleSubmit(onValid, onInvalid)` returns a submit handler in every framework. - **Extended HTML5 Renderers**: Built-in support for `radio`, `range`, `file`, `date`, `time`, `datetime-local`, and `switch`. - **Schema Validation Adapters**: Integrated `zodValidator`, `yupValidator`, `valibotValidator`, and Standard Schema adapters. - **Multi-Step Form Wizard Engine**: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`. - **Interactive Form DevTools**: Floating overlay component (``) for realtime debugging. - **Group Array Manipulation Helpers**: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, and `focusFirstInvalidField`. +#### Schema adapters + +Attach an adapter to a field's `validate` hook. By default the schema is treated +as an **object schema describing the whole form**, and a field name selects which +issues to surface: + +```ts +import { zodValidator } from '@dynamic-field-kit/core'; + +const schema = z.object({ email: z.string().email() }); + +const fields = [ + { name: 'email', type: 'text', validate: zodValidator(schema, 'email') }, +]; +``` + +For a **scalar schema** covering a single value, say so explicitly: + +```ts +validate: zodValidator(z.string().email(), { target: 'field' }); +``` + +Adapters parse **synchronously** so their result works with `validateFields` (and +therefore with `useDynamicForm`). A schema containing async refinements or async +`.test()` rules cannot be parsed synchronously — those return a Promise, so +validate through `validateFieldsAsync` instead. + --- ## 📦 Packages From 4250f1f534ebbb9af0ae09722f9bdb24ee9d0488 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:55:17 +0700 Subject: [PATCH 08/20] fix(core): add "switch" to FieldTypeMap Why: Both the react and vue defaultRenderersMap register a `switch` renderer, but FieldTypeMap had no `switch` entry - so `type: 'switch'` failed to typecheck and the shipped renderer was unreachable from TypeScript. The example app hit exactly this: `Type '"switch"' is not assignable to type 'FieldTypeKey'`. What: Add `switch: boolean` to FieldTypeMap, and a type test asserting every key the default renderer maps register is a usable field type, so the two cannot drift apart again. How to test: npm run test:types --workspace=@dynamic-field-kit/core --- packages/core/src/types.ts | 1 + packages/core/test/types.test-d.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a75af8c..f922530 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -16,6 +16,7 @@ export interface FieldTypeMap { date: string; time: string; 'datetime-local': string; + switch: boolean; } export type Properties = Record; diff --git a/packages/core/test/types.test-d.ts b/packages/core/test/types.test-d.ts index b8cf349..8211660 100644 --- a/packages/core/test/types.test-d.ts +++ b/packages/core/test/types.test-d.ts @@ -112,6 +112,26 @@ test('condition hooks return booleans', () => { >().returns.toEqualTypeOf(); }); +test('every built-in default renderer key is a usable field type', () => { + // These are the keys the react/vue `defaultRenderersMap` registers. A type + // that has a shipped renderer but no FieldTypeMap entry cannot be used from + // TypeScript at all. + assertType({ name: 'a', type: 'text' }); + assertType({ name: 'b', type: 'number' }); + assertType({ name: 'c', type: 'password' }); + assertType({ name: 'd', type: 'email' }); + assertType({ name: 'e', type: 'textarea' }); + assertType({ name: 'f', type: 'checkbox' }); + assertType({ name: 'g', type: 'select' }); + assertType({ name: 'h', type: 'radio' }); + assertType({ name: 'i', type: 'range' }); + assertType({ name: 'j', type: 'file' }); + assertType({ name: 'k', type: 'date' }); + assertType({ name: 'l', type: 'time' }); + assertType({ name: 'm', type: 'datetime-local' }); + assertType({ name: 'n', type: 'switch' }); +}); + test('FieldTypeMap augmentation resolves', () => { expectTypeOf().toEqualTypeOf<{ id: string }>(); assertType({ name: 'c', type: 'customType' }); From 5b9d3d5d772a8f120489ea652611dd91ebfff3ae Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 21:55:34 +0700 Subject: [PATCH 09/20] fix: stop shipping angular sources and drop a non-existent example prop Why: Two problems that CI cannot currently see, because the example apps are not workspaces and are not referenced by any workflow, and because the angular package has no `files` field. The example passed `onBlurField` to MultiFieldInput, which has no such prop - it manages blur internally. The page therefore did not compile. The angular tarball shipped src/ and test/ alongside dist/ - 61 files where react ships 8. What: - example: remove the `onBlurField` prop so the page compiles. - angular: add `files: ["dist"]`. Tarball drops 61 -> 33 files, dist/ only. Note: useDynamicForm's handleBlur/touched cannot currently be wired into MultiFieldInput at all. Worth a follow-up on whether the component should expose a blur hook. How to test: cd example/react-app && npx next build cd packages/angular && npm pack --dry-run --- example/react-app/app/new-features/page.tsx | 1 - packages/angular/package.json | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/example/react-app/app/new-features/page.tsx b/example/react-app/app/new-features/page.tsx index e197352..6f74188 100644 --- a/example/react-app/app/new-features/page.tsx +++ b/example/react-app/app/new-features/page.tsx @@ -123,7 +123,6 @@ export default function NewFeaturesPage() { fieldDescriptions={fields} properties={form.data} onChange={form.handleChange} - onBlurField={form.handleBlur} layout={{ type: 'responsive', mobile: 'column', diff --git a/packages/angular/package.json b/packages/angular/package.json index 854932a..ee2206b 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -9,6 +9,9 @@ "main": "dist/fesm2022/dynamic-field-kit-angular.mjs", "module": "dist/fesm2022/dynamic-field-kit-angular.mjs", "types": "dist/index.d.ts", + "files": [ + "dist" + ], "peerDependencies": { "@angular/common": ">=13 <22", "@angular/core": ">=13 <22", From f0ec57f4f2ffda33a70c3df71968ae9a6b383592 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 22:03:51 +0700 Subject: [PATCH 10/20] ci: gate releases on the same checks as PRs, and cover what CI could not see Why: Publishing was effectively ungated. Release triggered on push to master, did not depend on CI, and ran a thinner set of checks - no lint, no format, no coverage floors, no verify scripts. The two workflows are independent, as the 2026-08-04 history shows: CI succeeded on master at 16:13:10 while Release failed at 16:13:09 on the same commit. The reverse - publishing while CI is red - was equally possible. Three more blind spots: - The example apps are not workspaces and were referenced by no workflow, so nothing compiled them. Both bugs fixed in the previous two commits (a missing FieldTypeMap entry and a non-existent prop) were sitting in an example that had never been built in CI. - No check that a PR touching a package adds a changeset. The three feature commits on this branch had none. - No dependency audit. postcss reached production deps through vue. What: - Extract every gate into a reusable quality-gates.yml (workflow_call). ci.yml and release.yml both call it, so the two can no longer drift and release blocks on `needs: [gates]`. - Add an examples job building all three demo apps against the built dist. - Add a changeset job on pull_request, and an npm audit step for production dependencies. - Pin every job to .nvmrc via node-version-file. The workflows hardcoded Node 22 while .nvmrc said 24. - Add CODEOWNERS - master has require_code_owner_reviews enabled, which does nothing without this file - and a PR template. - Widen lint-staged globs to cover .cjs/.mjs/.yaml so pre-commit stops letting through files that `prettier --check .` then fails on in CI. - Configure commit.template from `prepare`; the template file existed but was never wired up. - Override postcss to ^8.5.25. `npm audit fix` cannot resolve it here because of the known ng-packagr peer conflict. Production audit is now clean, so the new audit step passes. - changesets baseBranch master -> develop, matching where PRs actually land. How to test: npm run lint && npm run format-check && npm run typecheck npm audit --omit=dev --audit-level=high npx changeset status --since=origin/develop cd example/ && npm ci && npm run build --- .changeset/config.json | 2 +- .github/CODEOWNERS | 10 ++ .github/pull_request_template.md | 25 ++++ .github/workflows/ci.yml | 132 ++------------------ .github/workflows/quality-gates.yml | 181 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 14 ++- package-lock.json | 43 +++++-- package.json | 9 +- 8 files changed, 283 insertions(+), 133 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/quality-gates.yml diff --git a/.changeset/config.json b/.changeset/config.json index 26fd612..7120e00 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,7 +5,7 @@ "fixed": [], "linked": [], "access": "public", - "baseBranch": "master", + "baseBranch": "develop", "updateInternalDependencies": "patch", "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9ff0b3a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# Branch protection on master enables require_code_owner_reviews, which does +# nothing without this file. Every path needs an owner for that rule to bite. +* @vannt-dev + +# Anything that decides what ships, or what a release does. +/.github/ @vannt-dev +/.changeset/ @vannt-dev +/scripts/ @vannt-dev +package.json @vannt-dev +package-lock.json @vannt-dev diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..817cf63 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## What + + + +## Why + + + +## How to test + + + +--- + +- [ ] Added a changeset (`npx changeset`) if any package under `packages/` changed +- [ ] Tests cover the change — a bug fix has a test that failed before it +- [ ] Public API changes are reflected in the README / package README +- [ ] Behaviour is consistent across react, vue and angular, or the difference is explained above + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e06ed8..e5a15ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,136 +10,30 @@ concurrency: cancel-in-progress: true jobs: - lint-and-build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Lint & Format - run: npm run lint && npm run format-check - - - name: Build packages - run: | - npm run build --workspace=@dynamic-field-kit/core - npm run build --workspace=@dynamic-field-kit/react - npm run build --workspace=@dynamic-field-kit/vue - npm run build --workspace=@dynamic-field-kit/angular - - # Share the built dist with the verify job so it doesn't rebuild all four - # packages (ng-packagr's Angular build especially). Common ancestor of the - # four paths is `packages`, so the artifact stores `/dist/...`. - - name: Upload built dist - uses: actions/upload-artifact@v4 - with: - name: package-dist - path: | - packages/core/dist - packages/react/dist - packages/vue/dist - packages/angular/dist - retention-days: 1 - if-no-files-found: error - - - name: Typecheck - run: npm run typecheck - - - name: Type tests - run: npm run test:types --workspace=@dynamic-field-kit/core - - - name: Show bundle sizes - run: node scripts/show-sizes.js + gates: + uses: ./.github/workflows/quality-gates.yml - test: - name: Test ${{ matrix.package }} + changeset: + name: Changeset present runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - package: core - test-cmd: npm run test --workspace=@dynamic-field-kit/core -- --coverage - coverage-file: packages/core/coverage/lcov.info - coverage-name: core - - package: react - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test --workspace=@dynamic-field-kit/react -- --coverage - coverage-file: packages/react/coverage/lcov.info - coverage-name: react - - package: vue - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test --workspace=@dynamic-field-kit/vue -- --coverage - coverage-file: packages/vue/coverage/lcov.info - coverage-name: vue - - package: angular - build-cmd: npm run build --workspace=@dynamic-field-kit/core - test-cmd: npm run test --workspace=@dynamic-field-kit/angular -- --coverage - coverage-file: packages/angular/coverage/lcov.info - coverage-name: angular + if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build core - if: matrix.build-cmd != '' - run: ${{ matrix.build-cmd }} - - - name: Run tests - run: ${{ matrix.test-cmd }} - - - name: Upload coverage - if: matrix.coverage-file != '' && success() - uses: codecov/codecov-action@v4 with: - files: ${{ matrix.coverage-file }} - flags: unittests - name: ${{ matrix.coverage-name }} - fail_ci_if_error: false - - verify: - runs-on: ubuntu-latest - needs: [lint-and-build, test] - steps: - - uses: actions/checkout@v4 + # changeset status diffs against the base branch, so it needs history. + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies run: npm ci - # Reuse the dist built by lint-and-build instead of rebuilding all four - # packages. Extract into `packages/` so paths become packages//dist. - - name: Download built dist - uses: actions/download-artifact@v4 - with: - name: package-dist - path: packages - - - name: Smoke test built packages - run: npm run test --workspace=@dynamic-field-kit/smoke - - - name: Run verification scripts - run: | - node scripts/verify-framework-deps.js - node scripts/check-cross-framework-imports.js - node scripts/integration-cross-registry.js + # Fails when a PR changes a publishable package without adding a + # changeset, which would otherwise merge and silently never be released. + # Add one with `npx changeset`. + - name: Check for a changeset + run: npx changeset status --since=origin/${{ github.base_ref }} diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 0000000..9b89bf1 --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,181 @@ +name: Quality Gates + +# The single definition of "is this commit good?". Called by both ci.yml and +# release.yml, so a publish can never run against checks weaker than a PR's. +on: + workflow_call: + +jobs: + lint-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint & Format + run: npm run lint && npm run format-check + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Build packages + run: | + npm run build --workspace=@dynamic-field-kit/core + npm run build --workspace=@dynamic-field-kit/react + npm run build --workspace=@dynamic-field-kit/vue + npm run build --workspace=@dynamic-field-kit/angular + + # Share the built dist with the verify and examples jobs so they don't + # rebuild all four packages (ng-packagr's Angular build especially). + # Common ancestor of the four paths is `packages`, so the artifact stores + # `/dist/...`. + - name: Upload built dist + uses: actions/upload-artifact@v4 + with: + name: package-dist + path: | + packages/core/dist + packages/react/dist + packages/vue/dist + packages/angular/dist + retention-days: 1 + if-no-files-found: error + + - name: Typecheck + run: npm run typecheck + + - name: Type tests + run: npm run test:types --workspace=@dynamic-field-kit/core + + - name: Show bundle sizes + run: node scripts/show-sizes.js + + test: + name: Test ${{ matrix.package }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package: core + test-cmd: npm run test --workspace=@dynamic-field-kit/core -- --coverage + coverage-file: packages/core/coverage/lcov.info + coverage-name: core + - package: react + build-cmd: npm run build --workspace=@dynamic-field-kit/core + test-cmd: npm run test --workspace=@dynamic-field-kit/react -- --coverage + coverage-file: packages/react/coverage/lcov.info + coverage-name: react + - package: vue + build-cmd: npm run build --workspace=@dynamic-field-kit/core + test-cmd: npm run test --workspace=@dynamic-field-kit/vue -- --coverage + coverage-file: packages/vue/coverage/lcov.info + coverage-name: vue + - package: angular + build-cmd: npm run build --workspace=@dynamic-field-kit/core + test-cmd: npm run test --workspace=@dynamic-field-kit/angular -- --coverage + coverage-file: packages/angular/coverage/lcov.info + coverage-name: angular + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build core + if: matrix.build-cmd != '' + run: ${{ matrix.build-cmd }} + + - name: Run tests + run: ${{ matrix.test-cmd }} + + - name: Upload coverage + if: matrix.coverage-file != '' && success() + uses: codecov/codecov-action@v4 + with: + files: ${{ matrix.coverage-file }} + flags: unittests + name: ${{ matrix.coverage-name }} + fail_ci_if_error: false + + examples: + name: Example ${{ matrix.app }} + runs-on: ubuntu-latest + needs: [lint-and-build] + strategy: + fail-fast: false + matrix: + app: [react-app, vue-app, angular-app] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: example/${{ matrix.app }}/package-lock.json + + # The example apps depend on the packages via `file:` paths, so the dist + # has to exist before their install resolves them. + - name: Download built dist + uses: actions/download-artifact@v4 + with: + name: package-dist + path: packages + + - name: Install example dependencies + working-directory: example/${{ matrix.app }} + run: npm ci --no-audit --no-fund + + # Each example's build typechecks its sources against the built packages, + # which is what catches a README/demo drifting from the real API. + - name: Build example + working-directory: example/${{ matrix.app }} + run: npm run build + + verify: + runs-on: ubuntu-latest + needs: [lint-and-build, test] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Reuse the dist built by lint-and-build instead of rebuilding all four + # packages. Extract into `packages/` so paths become packages//dist. + - name: Download built dist + uses: actions/download-artifact@v4 + with: + name: package-dist + path: packages + + - name: Smoke test built packages + run: npm run test --workspace=@dynamic-field-kit/smoke + + - name: Run verification scripts + run: | + node scripts/verify-framework-deps.js + node scripts/check-cross-framework-imports.js + node scripts/integration-cross-registry.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 169557f..41b4bf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,8 @@ on: branches: - master +# Deliberately no cancel-in-progress: cancelling mid-publish could leave some +# packages on npm and others not. concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: @@ -12,8 +14,15 @@ permissions: pull-requests: write jobs: + # Publishing used to run its own thinner set of checks (no lint, no format, + # no coverage floors, no verify scripts) independently of CI, so a red CI did + # not stop a release. The exact same gates a PR faces must pass first. + gates: + uses: ./.github/workflows/quality-gates.yml + release: name: Release & Publish + needs: [gates] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,7 +30,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'npm' registry-url: 'https://registry.npmjs.org' @@ -31,9 +40,6 @@ jobs: - name: Build packages run: npm run build - - name: Typecheck & Verify Tests - run: npm run typecheck && npm test - - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 diff --git a/package-lock.json b/package-lock.json index a9ef31c..32be7b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -885,6 +885,35 @@ "@napi-rs/nice": "^1.0.1" } }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -16876,9 +16905,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -17851,9 +17880,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -17871,7 +17900,7 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 66e6d98..81257c9 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,16 @@ "format-check": "prettier --check .", "typecheck": "npm run typecheck --workspaces --if-present", "format": "prettier --write .", - "prepare": "husky", + "prepare": "husky && git config commit.template .git-commit-template.txt || true", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset publish" }, "//": "root vitest is the hoisted peer target for @vitest/coverage-v8 - do not remove, it looks unused but breaks --coverage repo-wide", + "//overrides": "postcss reaches production deps through vue -> @vue/compiler-sfc. GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp affect <=8.5.22; this is a patch bump inside the range compiler-sfc already asks for. Drop once vue ships a compiler-sfc that requires a fixed postcss.", + "overrides": { + "postcss": "^8.5.25" + }, "devDependencies": { "@changesets/cli": "^2.31.1", "@commitlint/cli": "^21.2.1", @@ -51,12 +55,13 @@ "react-dom": "^19.2.4", "vue": "^3.5.32" }, + "//lint-staged": "globs must stay a superset of what `prettier --check .` sees, or a file can pass pre-commit and fail CI format-check. eslint only runs on the extensions the lint script targets.", "lint-staged": { "*.{ts,tsx,js,jsx}": [ "prettier --write", "eslint --fix" ], - "*.{json,md,yml,css}": [ + "*.{cjs,mjs,json,md,yml,yaml,css,scss,html}": [ "prettier --write" ] } From bb0e424b342ae6b7f2d4c5a9593d72f47f0e24e3 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 22:13:03 +0700 Subject: [PATCH 11/20] fix(ci): drop the postcss override that desynchronised package-lock Why: The override broke `npm ci` on Linux: "Missing: yaml@2.9.0 from lock file". A bare `postcss` key rewrites every postcss in the tree, including @angular-devkit/build-angular's nested copy, whose subtree resolves differently on Linux than on Windows. npm then had to re-resolve packages the lockfile had no entries for, so every CI job failed at install. The override was also unnecessary. vue's compiler-sfc asks for postcss ^8.5.8, and the advisories cover <=8.5.22 - so 8.5.25 was always inside the range npm was allowed to pick. The vulnerability existed only because the lockfile pinned 8.5.12. Refreshing that pin is the whole fix; no override is needed to hold it there. What: Remove the overrides block. The lockfile keeps postcss 8.5.25, which is a resolution npm reaches on its own, and `npm audit --omit=dev` reports zero vulnerabilities. How to test: npm ci && npm audit --omit=dev --audit-level=high --- package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package.json b/package.json index 81257c9..2748421 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,6 @@ "release": "changeset publish" }, "//": "root vitest is the hoisted peer target for @vitest/coverage-v8 - do not remove, it looks unused but breaks --coverage repo-wide", - "//overrides": "postcss reaches production deps through vue -> @vue/compiler-sfc. GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp affect <=8.5.22; this is a patch bump inside the range compiler-sfc already asks for. Drop once vue ships a compiler-sfc that requires a fixed postcss.", - "overrides": { - "postcss": "^8.5.25" - }, "devDependencies": { "@changesets/cli": "^2.31.1", "@commitlint/cli": "^21.2.1", From 8d5d1be2955207cf5b20d1292ebb435ce2cb45e9 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 22:21:42 +0700 Subject: [PATCH 12/20] fix(ci): declare Node 22 in .nvmrc, the version CI actually runs Why: Pointing the workflows at .nvmrc was meant to remove drift, but .nvmrc said 24 while every workflow had hardcoded 22 - so the "fix" silently upgraded CI by a major version, and install broke everywhere with: npm error Missing: yaml@2.9.0 from lock file Node 22 ships npm 10.x; Node 24.18 ships npm 11.16. lint-staged declares `yaml` as an optionalDependency, and npm 11.16 requires an entry for it in the lockfile where npm 10 does not. develop's lockfile has never had that entry, so this was latent, not caused by anything in this branch. Regenerating the lockfile on Windows is not the fix: npm 11.16 prunes every other platform's optional binaries while doing it, dropping 26 entries including @lmdb/lmdb-linux-x64 and @napi-rs/nice-linux-x64-gnu, which is exactly what Linux CI needs. What: Set .nvmrc to 22 so the declared version matches the one that is actually tested and that the committed lockfile supports. The workflows keep reading .nvmrc, so there is still a single source of truth. Moving to Node 24 is a real upgrade and needs its own PR: the lockfile has to be regenerated on Linux under npm 11.16 so it keeps the cross-platform optional binaries and gains the yaml entry. How to test: npm ci --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index a45fd52..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24 +22 From 85d08513032b36c7fb5db19e3e9277f802675c87 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 22:28:21 +0700 Subject: [PATCH 13/20] fix(ci): make the examples job actually install what the demos need Why: Two failures in the new job. example/angular-app gitignores its package-lock.json, so in a fresh checkout there is no lockfile: `cache-dependency-path` could not resolve it ("Some specified paths were not resolved") and `npm ci` had nothing to install from. example/react-app failed with module-not-found on @dynamic-field-kit/core from packages/react/dist/index.mjs. The `file:` deps resolve to the real packages/ directories, so Node walks up from there to the workspace root looking for core - and the job never ran a root install, so it was not there. It passed locally only because a root node_modules already existed. What: - Run `npm ci` at the workspace root before installing each example. - Use `npm install` for the examples so the lockfile-less angular app works; react and vue still honour their committed lockfiles. - Drop cache-dependency-path so the cache keys off the root lockfile, which always exists. How to test: rm -rf example/react-app/node_modules npm ci && cd example/react-app && npm install && npm run build --- .github/workflows/quality-gates.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 9b89bf1..f937858 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -128,7 +128,6 @@ jobs: with: node-version-file: '.nvmrc' cache: 'npm' - cache-dependency-path: example/${{ matrix.app }}/package-lock.json # The example apps depend on the packages via `file:` paths, so the dist # has to exist before their install resolves them. @@ -138,9 +137,18 @@ jobs: name: package-dist path: packages + # Those `file:` paths resolve to the real packages/ directories, so module + # resolution from packages/react/dist walks up to the workspace root - and + # without a root install it never finds @dynamic-field-kit/core. + - name: Install workspace dependencies + run: npm ci + + # `npm install`, not `npm ci`: example/angular-app gitignores its + # package-lock.json, so there is no lockfile to clean-install from. The + # other two still honour their committed lockfiles. - name: Install example dependencies working-directory: example/${{ matrix.app }} - run: npm ci --no-audit --no-fund + run: npm install --no-audit --no-fund # Each example's build typechecks its sources against the built packages, # which is what catches a README/demo drifting from the real API. From 28cff5937639f85a84b206dd4dab438d262262ff Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 22:34:43 +0700 Subject: [PATCH 14/20] ci: skip the changeset check on changesets' own release PR The release PR consumes changesets and bumps versions, so it changes packages while correctly having no changeset left. Requiring the check without this would deadlock every release. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5a15ab..f8dc2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,12 @@ jobs: changeset: name: Changeset present runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + # Skipped on the release PR changesets itself opens: that PR consumes the + # changesets and bumps versions, so it changes packages while legitimately + # having none left, and would always fail this check. + if: >- + github.event_name == 'pull_request' && + !startsWith(github.head_ref, 'changeset-release/') steps: - uses: actions/checkout@v4 with: From 3976ab30213cfffd36fd40e721b7ce47099bd7fa Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 22:50:41 +0700 Subject: [PATCH 15/20] feat(ci): release by picking a bump from the Actions UI Why: Releases were being cut by hand - #25 edited versions directly and #27 then had to delete consumed changeset files manually. Changesets was installed but half-used, so the version bump was manual work followed by a commit. What: Release is now workflow_dispatch only, with inputs: bump patch | minor | major packages core,react,vue,angular (empty = all) message the CHANGELOG entry dry_run version and print, publish nothing scripts/create-changeset.js turns those inputs into a real changeset, so the run goes: quality gates -> changeset version -> lockfile sync -> build -> commit -> changeset publish. No version is edited by hand. Packages stay independently versioned, and changesets already committed are consumed in the same run with the largest bump per package winning. The push-to-master trigger is gone. It opened a "version packages" PR through changesets/action, which is a second, competing release path - the same overlap that produced the manual cleanup in #27. Run it on develop, not master: required status checks apply to direct pushes, so the Actions bot cannot push the release commit to master. Versions reach master through the usual develop -> master PR. Verified locally against the real changeset on this branch: core 1.3.0 -> 1.4.0, react/vue/angular 1.4.0 -> 1.5.0, internal deps rewritten to ^1.4.0, CHANGELOGs generated. The lockfile sync step was checked under npm 10 (what Node 22 ships, per .nvmrc) and keeps every platform's optional binaries - npm 11 prunes them, which is what broke install earlier on this branch. How to test: Actions > Release > Run workflow, on develop, with dry_run enabled. --- .github/workflows/release.yml | 101 ++++++++++++++++++++++---- README.md | 25 +++++++ package.json | 1 + scripts/create-changeset.js | 133 ++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 scripts/create-changeset.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41b4bf6..741cfe6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,39 @@ name: Release +# Manual only. Pick a bump, pick the packages, press Run - the workflow does +# the version bump, the CHANGELOG, the commit and the npm publish. Nothing is +# versioned by hand. +# +# Run it on `develop`. master requires status checks, and those apply to direct +# pushes too, so the release commit cannot be pushed there by the Actions bot. +# The version bump reaches master through the normal develop -> master PR. on: - push: - branches: - - master + workflow_dispatch: + inputs: + bump: + description: 'Version bump to apply' + type: choice + options: + - patch + - minor + - major + default: patch + required: true + packages: + description: 'Packages to bump, comma separated (core,react,vue,angular). Leave empty for all.' + type: string + default: '' + required: false + message: + description: 'CHANGELOG entry for this release' + type: string + default: '' + required: false + dry_run: + description: 'Version and print what would be published, but do not publish' + type: boolean + default: false + required: false # Deliberately no cancel-in-progress: cancelling mid-publish could leave some # packages on npm and others not. @@ -11,12 +41,10 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write - pull-requests: write jobs: - # Publishing used to run its own thinner set of checks (no lint, no format, - # no coverage floors, no verify scripts) independently of CI, so a red CI did - # not stop a release. The exact same gates a PR faces must pass first. + # The exact gates a PR faces, so a release can never ship something CI would + # have rejected. gates: uses: ./.github/workflows/quality-gates.yml @@ -26,6 +54,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -37,18 +68,56 @@ jobs: - name: Install dependencies run: npm ci + # Turns the dispatch inputs into a real changeset. Any changesets already + # committed are consumed in the same pass, and the largest bump per + # package wins. + - name: Create changeset from inputs + run: | + node scripts/create-changeset.js \ + --bump "${{ inputs.bump }}" \ + --packages "${{ inputs.packages }}" \ + --message "${{ inputs.message }}" + + - name: Apply versions and changelogs + run: npx changeset version + + # changeset version rewrites the workspace package.json versions, which + # the lockfile records too - without this the next `npm ci` fails. + - name: Sync lockfile + run: npm install --package-lock-only --no-audit --no-fund + + - name: Show resulting versions + run: | + for pkg in core react vue angular; do + echo "$pkg -> $(node -p "require('./packages/$pkg/package.json').version")" + done + - name: Build packages run: npm run build - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: changesets/action@v1 - with: - version: npm run version-packages - publish: npm run release - commit: 'chore: release packages' - title: 'chore(release): version packages' + - name: Commit the release + if: ${{ !inputs.dry_run }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'chore(release): version packages' + git push origin HEAD:${{ github.ref_name }} + + # changeset publish pushes each package that is not already on npm at its + # current version, and tags the commit per package. + - name: Publish to npm + if: ${{ !inputs.dry_run }} + run: | + npx changeset publish + git push origin --follow-tags env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + echo 'Dry run - nothing was committed, tagged or published.' + echo 'Versions that would have shipped are listed above.' + git --no-pager diff --stat diff --git a/README.md b/README.md index 4ad797b..eaef621 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,31 @@ This library intentionally does not include: It is a **form engine**, not a full form framework. +## 🚀 Releasing + +Versions are never edited by hand. Go to **Actions → Release → Run workflow**, +run it on `develop`, and fill in: + +| Input | Meaning | +| ---------- | ---------------------------------------------------------------------- | +| `bump` | `patch`, `minor` or `major` | +| `packages` | `core,react,vue,angular` — leave empty to bump all of them | +| `message` | The CHANGELOG entry for this release | +| `dry_run` | Version and print the result without committing, tagging or publishing | + +The workflow runs the full quality gates first, then applies the bump, writes +the CHANGELOGs, commits `chore(release): version packages`, and publishes to +npm. + +Packages are versioned independently, so bumping only what changed is fine. +Any changesets already committed (`npx changeset`, or +`npm run changeset:auto -- --bump minor --packages core --message "..."`) are +consumed in the same run, and the largest bump per package wins. + +Run it on `develop`, not `master`: master requires status checks, and those +apply to direct pushes too, so the Actions bot cannot push the release commit +there. The new versions reach master through the usual `develop → master` PR. + ## 📄 License MIT © [vannt-dev](https://github.com/vannt-dev) diff --git a/package.json b/package.json index 2748421..2aafb23 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format": "prettier --write .", "prepare": "husky && git config commit.template .git-commit-template.txt || true", "changeset": "changeset", + "changeset:auto": "node scripts/create-changeset.js", "version-packages": "changeset version", "release": "changeset publish" }, diff --git a/scripts/create-changeset.js b/scripts/create-changeset.js new file mode 100644 index 0000000..0a5b2ad --- /dev/null +++ b/scripts/create-changeset.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Writes a changeset file from command-line arguments instead of the + * interactive `npx changeset` prompt, so a release can be triggered from CI + * (or a one-liner locally) by picking a bump level. + * + * node scripts/create-changeset.js --bump minor --packages core,react \ + * --message "Add schema adapters" + * + * Omitting --packages targets every publishable package. Any changesets that + * are already pending are left alone: `changeset version` merges them all and + * the largest bump per package wins. + */ + +const fs = require('fs'); +const path = require('path'); + +const VALID_BUMPS = ['patch', 'minor', 'major']; +const REPO_ROOT = path.resolve(__dirname, '..'); +const PACKAGES_DIR = path.join(REPO_ROOT, 'packages'); +const CHANGESET_DIR = path.join(REPO_ROOT, '.changeset'); + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + continue; + } + const key = arg.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i += 1; + } + } + return args; +} + +/** Every package under packages/ that is actually published to npm. */ +function readPublishablePackages() { + return fs + .readdirSync(PACKAGES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const manifestPath = path.join(PACKAGES_DIR, entry.name, 'package.json'); + if (!fs.existsSync(manifestPath)) { + return undefined; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (!manifest.name || manifest.private === true) { + return undefined; + } + return { dir: entry.name, name: manifest.name }; + }) + .filter(Boolean); +} + +/** Accepts either `core` or `@dynamic-field-kit/core`. */ +function resolveRequested(requested, publishable) { + return requested.map((token) => { + const match = publishable.find( + (pkg) => pkg.name === token || pkg.dir === token + ); + if (!match) { + const known = publishable.map((p) => p.dir).join(', '); + throw new Error(`Unknown package "${token}". Known packages: ${known}`); + } + return match.name; + }); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const bump = String(args.bump || '').trim(); + + if (!VALID_BUMPS.includes(bump)) { + throw new Error( + `--bump must be one of ${VALID_BUMPS.join(', ')} (received "${bump}")` + ); + } + + const publishable = readPublishablePackages(); + if (publishable.length === 0) { + throw new Error(`No publishable packages found under ${PACKAGES_DIR}`); + } + + const requestedRaw = + typeof args.packages === 'string' ? args.packages.trim() : ''; + const names = requestedRaw + ? resolveRequested( + requestedRaw + .split(',') + .map((token) => token.trim()) + .filter(Boolean), + publishable + ) + : publishable.map((pkg) => pkg.name); + + if (names.length === 0) { + throw new Error('--packages resolved to an empty list'); + } + + const message = + (typeof args.message === 'string' && args.message.trim()) || + `Release ${bump} version`; + + const frontMatter = names.map((name) => `'${name}': ${bump}`).join('\n'); + const body = `---\n${frontMatter}\n---\n\n${message}\n`; + + fs.mkdirSync(CHANGESET_DIR, { recursive: true }); + const fileName = `auto-${bump}-${Date.now()}.md`; + const filePath = path.join(CHANGESET_DIR, fileName); + fs.writeFileSync(filePath, body, 'utf8'); + + process.stdout.write( + `Created .changeset/${fileName}\n` + + ` bump: ${bump}\n` + + ` packages: ${names.join(', ')}\n` + + ` message: ${message}\n` + ); +} + +try { + main(); +} catch (error) { + process.stderr.write(`create-changeset: ${error.message}\n`); + process.exit(1); +} From 8b6475cba328c9fbdf00c91bf74e6207a6e6f5bb Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 23:14:56 +0700 Subject: [PATCH 16/20] feat(core): let the wizard engine actually change step Why: The engine shipped canGoNext and canGoPrev - it could say whether moving was allowed, but there was no function to move. Callers had to rebuild state with createWizardState(steps, i + 1), which resets completedSteps to [] every time. completedSteps was written in exactly one place, its initialiser, and read nowhere: declared state that nothing maintained. The README advertises a "Multi-Step Form Wizard Engine", so this was the gap between the claim and what the code could do. What: - goNext / goPrev / goToStep, all returning new state and leaving the input untouched. goNext records the step it leaves in completedSteps, so the set is now maintained rather than decorative. - markStepCompleted and isStepCompleted for driving a step indicator. - goNext at the last step and goPrev at the first return the *same* state object, so callers can compare identity to detect a no-op. goNext does not validate. Validation stays explicit through validateStep, so a wizard can allow moving on from an incomplete step if it wants to. How to test: npm run test --workspace=@dynamic-field-kit/core --- packages/core/src/wizard.ts | 89 +++++++++++++++++++++- packages/core/test/wizard.test.ts | 122 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/packages/core/src/wizard.ts b/packages/core/src/wizard.ts index 29b8dcf..226686a 100644 --- a/packages/core/src/wizard.ts +++ b/packages/core/src/wizard.ts @@ -18,22 +18,52 @@ export interface WizardState { completedSteps: number[]; } +const EMPTY_STEP: FormStep = { id: '', title: '', fields: [] }; + +function clampIndex(index: number, stepCount: number): number { + return Math.max(0, Math.min(index, stepCount - 1)); +} + export function createWizardState( steps: FormStep[], initialStepIndex = 0 ): WizardState { - const safeIndex = Math.max(0, Math.min(initialStepIndex, steps.length - 1)); + const safeIndex = clampIndex(initialStepIndex, steps.length); return { currentStepIndex: safeIndex, totalSteps: steps.length, isFirstStep: safeIndex === 0, isLastStep: safeIndex === steps.length - 1, - currentStep: steps[safeIndex] || { id: '', title: '', fields: [] }, + currentStep: steps[safeIndex] || EMPTY_STEP, steps, completedSteps: [], }; } +/** Rebuilds the derived flags for a new position. Never mutates `state`. */ +function atStep( + state: WizardState, + index: number, + completedSteps: number[] +): WizardState { + const safeIndex = clampIndex(index, state.steps.length); + return { + ...state, + currentStepIndex: safeIndex, + isFirstStep: safeIndex === 0, + isLastStep: safeIndex === state.steps.length - 1, + currentStep: state.steps[safeIndex] || EMPTY_STEP, + completedSteps, + }; +} + +function withCompleted(completedSteps: number[], index: number): number[] { + if (completedSteps.includes(index)) { + return completedSteps; + } + return [...completedSteps, index].sort((a, b) => a - b); +} + export function validateStep( step: FormStep, data: Properties @@ -48,3 +78,58 @@ export function canGoNext(state: WizardState): boolean { export function canGoPrev(state: WizardState): boolean { return state.currentStepIndex > 0; } + +/** Has this step already been left behind via `goNext` or marked complete? */ +export function isStepCompleted(state: WizardState, index: number): boolean { + return state.completedSteps.includes(index); +} + +/** + * Records a step as completed. Defaults to the current step. Returns the same + * state when the index is out of range or already recorded. + */ +export function markStepCompleted( + state: WizardState, + index: number = state.currentStepIndex +): WizardState { + if (index < 0 || index >= state.steps.length) { + return state; + } + const completedSteps = withCompleted(state.completedSteps, index); + if (completedSteps === state.completedSteps) { + return state; + } + return { ...state, completedSteps }; +} + +/** + * Moves to `index`, clamped to the available steps. Jumping around does not + * mark anything completed - only `goNext` and `markStepCompleted` do that. + */ +export function goToStep(state: WizardState, index: number): WizardState { + return atStep(state, index, state.completedSteps); +} + +/** + * Advances one step, recording the step being left as completed. Returns the + * same state when already on the last step, so callers can compare identity. + * Validate first with `validateStep` when the step must be valid to leave. + */ +export function goNext(state: WizardState): WizardState { + if (!canGoNext(state)) { + return state; + } + return atStep( + state, + state.currentStepIndex + 1, + withCompleted(state.completedSteps, state.currentStepIndex) + ); +} + +/** Goes back one step, keeping the completed set intact. */ +export function goPrev(state: WizardState): WizardState { + if (!canGoPrev(state)) { + return state; + } + return atStep(state, state.currentStepIndex - 1, state.completedSteps); +} diff --git a/packages/core/test/wizard.test.ts b/packages/core/test/wizard.test.ts index a44319d..42bd40f 100644 --- a/packages/core/test/wizard.test.ts +++ b/packages/core/test/wizard.test.ts @@ -4,6 +4,11 @@ import { validateStep, canGoNext, canGoPrev, + goNext, + goPrev, + goToStep, + isStepCompleted, + markStepCompleted, FormStep, } from '../src/wizard'; @@ -97,3 +102,120 @@ describe('Wizard Core Module', () => { ); }); }); + +describe('Wizard navigation', () => { + const threeSteps: FormStep[] = [ + { id: 'a', title: 'A', fields: [] }, + { id: 'b', title: 'B', fields: [] }, + { id: 'c', title: 'C', fields: [] }, + ]; + + it('advances to the next step', () => { + const next = goNext(createWizardState(threeSteps)); + + expect(next.currentStepIndex).toBe(1); + expect(next.currentStep.id).toBe('b'); + expect(next.isFirstStep).toBe(false); + expect(next.isLastStep).toBe(false); + }); + + it('marks the step it leaves as completed', () => { + const next = goNext(createWizardState(threeSteps)); + + expect(next.completedSteps).toEqual([0]); + expect(isStepCompleted(next, 0)).toBe(true); + expect(isStepCompleted(next, 1)).toBe(false); + }); + + it('accumulates completed steps across advances', () => { + const state = goNext(goNext(createWizardState(threeSteps))); + + expect(state.currentStepIndex).toBe(2); + expect(state.isLastStep).toBe(true); + expect(state.completedSteps).toEqual([0, 1]); + }); + + it('does not advance past the last step', () => { + const last = createWizardState(threeSteps, 2); + + expect(goNext(last)).toBe(last); + }); + + it('goes back a step', () => { + const state = goPrev(goNext(createWizardState(threeSteps))); + + expect(state.currentStepIndex).toBe(0); + expect(state.isFirstStep).toBe(true); + }); + + it('keeps completed steps when going back', () => { + const state = goPrev(goNext(createWizardState(threeSteps))); + + expect(state.completedSteps).toEqual([0]); + }); + + it('does not go back past the first step', () => { + const first = createWizardState(threeSteps); + + expect(goPrev(first)).toBe(first); + }); + + it('jumps to an arbitrary step without marking it completed', () => { + const state = goToStep(createWizardState(threeSteps), 2); + + expect(state.currentStepIndex).toBe(2); + expect(state.currentStep.id).toBe('c'); + expect(state.completedSteps).toEqual([]); + }); + + it('clamps a jump to the available range', () => { + const state = createWizardState(threeSteps); + + expect(goToStep(state, 99).currentStepIndex).toBe(2); + expect(goToStep(state, -5).currentStepIndex).toBe(0); + }); + + it('marks the current step completed on demand', () => { + const state = markStepCompleted(createWizardState(threeSteps)); + + expect(state.completedSteps).toEqual([0]); + expect(state.currentStepIndex).toBe(0); + }); + + it('marks an explicit step completed, without duplicates', () => { + let state = markStepCompleted(createWizardState(threeSteps), 2); + state = markStepCompleted(state, 2); + + expect(state.completedSteps).toEqual([2]); + }); + + it('keeps completed steps in order', () => { + let state = markStepCompleted(createWizardState(threeSteps), 2); + state = markStepCompleted(state, 0); + + expect(state.completedSteps).toEqual([0, 2]); + }); + + it('ignores a completion mark outside the range', () => { + const state = createWizardState(threeSteps); + + expect(markStepCompleted(state, 99)).toBe(state); + expect(markStepCompleted(state, -1)).toBe(state); + }); + + it('does not mutate the state it is given', () => { + const state = createWizardState(threeSteps); + goNext(state); + + expect(state.currentStepIndex).toBe(0); + expect(state.completedSteps).toEqual([]); + }); + + it('stays usable with no steps', () => { + const empty = createWizardState([]); + + expect(goNext(empty)).toBe(empty); + expect(goPrev(empty)).toBe(empty); + expect(goToStep(empty, 3).currentStepIndex).toBe(0); + }); +}); From abf7fab8a4ec4502e6de9e955311074047fa41b3 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 23:15:27 +0700 Subject: [PATCH 17/20] feat(angular): show the error count in DevTools, matching react and vue The react and vue overlays put a red count badge on the collapsed button and render the errors tab as "errors (N)". Angular had neither, so the one framework where you cannot see errors at a glance was the one whose overlay looked identical otherwise. --- .../src/components/DynamicFormDevTools.ts | 24 +++++++++++++- .../angular/test/DynamicFormDevTools.spec.ts | 33 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/angular/src/components/DynamicFormDevTools.ts b/packages/angular/src/components/DynamicFormDevTools.ts index 0b2a348..439f0be 100644 --- a/packages/angular/src/components/DynamicFormDevTools.ts +++ b/packages/angular/src/components/DynamicFormDevTools.ts @@ -37,6 +37,18 @@ import { FieldDescription, Properties } from '@dynamic-field-kit/core'; " > 🔍 DevTools + {{ errorCount() }}
- {{ tab }} + {{ tab + }}{{ + tab === 'errors' && errorCount() > 0 + ? ' (' + errorCount() + ')' + : '' + }}
@@ -138,6 +155,11 @@ export class DynamicFormDevToolsComponent { @Input() isDirty = false; @Input() fields: FieldDescription[] = []; + /** Number of fields carrying errors, mirroring the react and vue overlays. */ + errorCount(): number { + return Object.keys(this.errors || {}).length; + } + isOpen = signal(false); activeTab = signal<'data' | 'errors' | 'meta' | 'fields'>('data'); tabs: Array<'data' | 'errors' | 'meta' | 'fields'> = [ diff --git a/packages/angular/test/DynamicFormDevTools.spec.ts b/packages/angular/test/DynamicFormDevTools.spec.ts index 751969e..e9f20b8 100644 --- a/packages/angular/test/DynamicFormDevTools.spec.ts +++ b/packages/angular/test/DynamicFormDevTools.spec.ts @@ -50,6 +50,39 @@ describe('DynamicFormDevToolsComponent', () => { expect(text(fixture)).toContain('🛠️ Form DevTools'); }); + it('shows no error badge when the form is clean', () => { + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.dfk-devtools-badge' + ) + ).toBeNull(); + }); + + it('shows the error count on the collapsed badge', () => { + // setInput, not a plain assignment: the component is OnPush, so only a real + // input binding marks it for check - which is what an app does. + fixture.componentRef.setInput('errors', { + name: ['required'], + email: ['invalid'], + }); + fixture.detectChanges(); + + const badge = (fixture.nativeElement as HTMLElement).querySelector( + '.dfk-devtools-badge' + ); + expect(badge?.textContent?.trim()).toBe('2'); + }); + + it('shows the error count in the errors tab label', () => { + fixture.componentInstance.errors = { name: ['required'] }; + open(fixture); + + const tab = buttons(fixture).find((b) => + (b.textContent || '').trim().toLowerCase().startsWith('errors') + ); + expect(tab?.textContent?.replace(/\s+/g, ' ').trim()).toBe('errors (1)'); + }); + it('shows form data on the data tab', () => { fixture.componentInstance.data = { email: 'a@b.com' }; open(fixture); From e97baacf23ff2ba5d3e96d2fbbf820069dd74d6c Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 23:15:33 +0700 Subject: [PATCH 18/20] feat: report field blur from MultiFieldInput in all three frameworks Why: useDynamicForm ships handleBlur, touched and validateOnBlur, but there was no way to connect them to MultiFieldInput - the component that actually renders the form. The example app tried, with onBlurField, and did not compile. Worse, blur plumbing existed only in react. Vue and Angular had none at any level: their FieldInput never passed onBlur down, and Vue's DynamicInput did not declare it - so the vue default renderers accepted an onBlur prop that could never arrive. What: - react: MultiFieldInput takes an optional onBlurField, called alongside the touched tracking it already did internally. - vue: onBlur and touched threaded through DynamicInput -> FieldInput -> MultiFieldInput, plus internal touched tracking to match react. - angular: FieldInput emits onBlurField from a `focusout` listener - it bubbles, so any renderer works without declaring a blur output of its own. MultiFieldInput re-emits it and exposes isTouched(). - example: restore onBlurField, now that the prop exists. How to test: npm run test --workspace=@dynamic-field-kit/{react,vue,angular} cd example/react-app && npm run build --- example/react-app/app/new-features/page.tsx | 1 + packages/angular/src/components/FieldInput.ts | 7 ++ .../angular/src/components/MultiFieldInput.ts | 20 +++++ .../angular/test/MultiFieldInputBlur.spec.ts | 76 ++++++++++++++++++ .../react/src/components/MultiFieldInput.tsx | 11 +++ .../react/test/MultiFieldInputBlur.test.tsx | 64 +++++++++++++++ packages/vue/src/components/DynamicInput.ts | 10 +++ packages/vue/src/components/FieldInput.ts | 10 +++ .../vue/src/components/MultiFieldInput.ts | 16 ++++ packages/vue/test/MultiFieldInputBlur.test.ts | 78 +++++++++++++++++++ 10 files changed, 293 insertions(+) create mode 100644 packages/angular/test/MultiFieldInputBlur.spec.ts create mode 100644 packages/react/test/MultiFieldInputBlur.test.tsx create mode 100644 packages/vue/test/MultiFieldInputBlur.test.ts diff --git a/example/react-app/app/new-features/page.tsx b/example/react-app/app/new-features/page.tsx index 6f74188..e197352 100644 --- a/example/react-app/app/new-features/page.tsx +++ b/example/react-app/app/new-features/page.tsx @@ -123,6 +123,7 @@ export default function NewFeaturesPage() { fieldDescriptions={fields} properties={form.data} onChange={form.handleChange} + onBlurField={form.handleBlur} layout={{ type: 'responsive', mobile: 'column', diff --git a/packages/angular/src/components/FieldInput.ts b/packages/angular/src/components/FieldInput.ts index 6c6f75e..cb8aeff 100644 --- a/packages/angular/src/components/FieldInput.ts +++ b/packages/angular/src/components/FieldInput.ts @@ -31,6 +31,7 @@ import { DynamicInput } from './DynamicInput'; (valueChange)=" onValueChangeField.emit({ value: $event, key: fieldDescription!.name }) " + (focusout)="onBlurField.emit(fieldDescription!.name)" [disabled]="disabled" [readOnly]="readOnly" [error]="$any(error)" @@ -49,6 +50,12 @@ export class FieldInput implements OnChanges { value: unknown; key: string; }>(); + /** + * Emits this field's name when focus leaves it. Driven by `focusout`, which + * bubbles, so it works for any renderer without the renderer having to + * declare a blur output of its own. + */ + @Output() onBlurField = new EventEmitter(); shouldRender = false; diff --git a/packages/angular/src/components/MultiFieldInput.ts b/packages/angular/src/components/MultiFieldInput.ts index 927964e..4161031 100644 --- a/packages/angular/src/components/MultiFieldInput.ts +++ b/packages/angular/src/components/MultiFieldInput.ts @@ -64,6 +64,7 @@ const DEFAULT_BREAKPOINT = 768; [readOnly]="getReadOnly(field)" [error]="getError(field)" (onValueChangeField)="onFieldChange($event)" + (onBlurField)="handleBlurField($event)" >
@@ -119,6 +120,25 @@ export class MultiFieldInput implements OnInit, OnChanges { @Input() properties?: Properties; @Output() onChange = new EventEmitter(); @Output() validityChange = new EventEmitter(); + /** + * Emits a field's name when it loses focus. Touched state is tracked + * internally either way; this is the hook for driving an external form store + * - pass `createDynamicFormStore`'s `handleBlur` to get its `touched` map and + * `validateOnBlur` behaviour. + */ + @Output() onBlurField = new EventEmitter(); + + private touchedFields: Record = {}; + + handleBlurField(fieldName: string): void { + this.touchedFields = { ...this.touchedFields, [fieldName]: true }; + this.onBlurField.emit(fieldName); + } + + /** Whether this field has been blurred at least once. */ + isTouched(fieldName: string): boolean { + return Boolean(this.touchedFields[fieldName]); + } @Input() layout: LayoutConfig = 'column'; // Top-level form data, threaded down through repeatable groups so a nested // field's appearCondition/computeValue can read the root form. Omitted at diff --git a/packages/angular/test/MultiFieldInputBlur.spec.ts b/packages/angular/test/MultiFieldInputBlur.spec.ts new file mode 100644 index 0000000..d6d1cf4 --- /dev/null +++ b/packages/angular/test/MultiFieldInputBlur.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FieldDescription } from '@dynamic-field-kit/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MultiFieldInput } from '../src/components/MultiFieldInput'; +import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; +import { makeRegistry, TextRendererComponent } from './helpers/renderers'; + +const fields: FieldDescription[] = [ + { name: 'first', type: 'text', label: 'First' }, + { name: 'second', type: 'text', label: 'Second' }, +]; + +describe('MultiFieldInput blur reporting', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + const registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', fields); + fixture.detectChanges(); + }); + + function inputs(): HTMLElement[] { + return Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('input') + ); + } + + it('reports which field was blurred', () => { + const seen: string[] = []; + fixture.componentInstance.onBlurField.subscribe((name: string) => + seen.push(name) + ); + + inputs()[1].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + + expect(seen).toEqual(['second']); + }); + + it('reports each field separately', () => { + const seen: string[] = []; + fixture.componentInstance.onBlurField.subscribe((name: string) => + seen.push(name) + ); + + inputs()[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + inputs()[1].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + + expect(seen).toEqual(['first', 'second']); + }); + + it('does not throw when nothing is subscribed', () => { + expect(() => { + inputs()[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('marks the blurred field touched', () => { + const spy = vi.fn(); + fixture.componentInstance.onBlurField.subscribe(spy); + + inputs()[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + + expect(fixture.componentInstance.isTouched('first')).toBe(true); + expect(fixture.componentInstance.isTouched('second')).toBe(false); + }); +}); diff --git a/packages/react/src/components/MultiFieldInput.tsx b/packages/react/src/components/MultiFieldInput.tsx index ec2baf7..73d3a66 100644 --- a/packages/react/src/components/MultiFieldInput.tsx +++ b/packages/react/src/components/MultiFieldInput.tsx @@ -33,6 +33,13 @@ interface Props { * included). */ onValidityChange?: (result: ValidationResult) => void; + /** + * Called with a field's name when it loses focus. Touched state is still + * tracked internally either way; this is the hook for driving an external + * form store - pass `useDynamicForm`'s `handleBlur` to get its `touched` + * map and `validateOnBlur` behaviour. + */ + onBlurField?: (fieldName: string) => void; } function resolveLayout(layout?: LayoutConfig) { @@ -52,6 +59,7 @@ const MultiFieldInput = ({ layout, rootData, onValidityChange, + onBlurField, }: Props) => { const [data, setData] = useState({}); const [touchedFields, setTouchedFields] = useState>( @@ -92,6 +100,8 @@ const MultiFieldInput = ({ rootDataRef.current = rootData; const onValidityChangeRef = useRef(onValidityChange); onValidityChangeRef.current = onValidityChange; + const onBlurFieldRef = useRef(onBlurField); + onBlurFieldRef.current = onBlurField; useEffect(() => { onValidityChangeRef.current?.( @@ -113,6 +123,7 @@ const MultiFieldInput = ({ const handleBlurField = useCallback((key: string) => { setTouchedFields((prev) => (prev[key] ? prev : { ...prev, [key]: true })); + onBlurFieldRef.current?.(key); }, []); const { type, config } = resolveLayout(layout); diff --git a/packages/react/test/MultiFieldInputBlur.test.tsx b/packages/react/test/MultiFieldInputBlur.test.tsx new file mode 100644 index 0000000..c32b6f1 --- /dev/null +++ b/packages/react/test/MultiFieldInputBlur.test.tsx @@ -0,0 +1,64 @@ +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import MultiFieldInput from '../src/components/MultiFieldInput'; +import { fieldRegistry } from '../src/fieldRegistry'; +import '../src/layout/defaultLayouts'; + +declare module '@dynamic-field-kit/core' { + interface FieldTypeMap { + text: string; + } +} + +const fields: FieldDescription[] = [ + { name: 'first', type: 'text', label: 'First' }, + { name: 'second', type: 'text', label: 'Second' }, +]; + +describe('MultiFieldInput blur reporting', () => { + beforeEach(() => { + fieldRegistry.register('text', ({ value, onValueChange, onBlur, id }) => ( + onValueChange?.(e.target.value)} + onBlur={onBlur} + /> + )); + }); + + it('reports which field was blurred', () => { + const onBlurField = vi.fn(); + render( + + ); + + fireEvent.blur(screen.getByTestId('dfk-field-second')); + + expect(onBlurField).toHaveBeenCalledWith('second'); + }); + + it('reports each field separately', () => { + const onBlurField = vi.fn(); + render( + + ); + + fireEvent.blur(screen.getByTestId('dfk-field-first')); + fireEvent.blur(screen.getByTestId('dfk-field-second')); + + expect(onBlurField.mock.calls.map(([name]) => name)).toEqual([ + 'first', + 'second', + ]); + }); + + it('still tracks touched internally when no handler is passed', () => { + expect(() => { + render(); + fireEvent.blur(screen.getByTestId('dfk-field-first')); + }).not.toThrow(); + }); +}); diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts index 2f8cb82..afe71ba 100644 --- a/packages/vue/src/components/DynamicInput.ts +++ b/packages/vue/src/components/DynamicInput.ts @@ -20,6 +20,14 @@ const DynamicInput = defineComponent({ type: Function as PropType<(value: unknown) => void>, default: undefined, }, + onBlur: { + type: Function as PropType<() => void>, + default: undefined, + }, + touched: { + type: Boolean, + default: undefined, + }, label: { type: String, default: undefined, @@ -70,6 +78,8 @@ const DynamicInput = defineComponent({ ...props.extraProps, value: props.value, 'onUpdate:value': props.onChange, + onBlur: props.onBlur, + touched: props.touched, label: props.label, options: props.options, class: props.className, diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts index ab4fd4e..d4d25eb 100644 --- a/packages/vue/src/components/FieldInput.ts +++ b/packages/vue/src/components/FieldInput.ts @@ -28,6 +28,14 @@ const FieldInput = defineComponent({ type: Function as PropType<(value: unknown, key: string) => void>, required: true, }, + onBlurField: { + type: Function as PropType<(key: string) => void>, + default: undefined, + }, + touched: { + type: Boolean, + default: undefined, + }, }, setup(props) { return () => { @@ -82,7 +90,9 @@ const FieldInput = defineComponent({ ariaInvalid: Boolean(errorList), ariaRequired: Boolean(required), extraProps, + touched: props.touched, onChange: (v: unknown) => props.onValueChangeField(v, name), + onBlur: () => props.onBlurField?.(name), }); }; }, diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index ce27493..eecb661 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -70,6 +70,14 @@ const MultiFieldInput = defineComponent({ type: Function as PropType<(result: ValidationResult) => void>, default: undefined, }, + // Called with a field's name when it loses focus. Touched state is tracked + // internally either way; this is the hook for driving an external form + // store - pass `useDynamicForm`'s `handleBlur` to get its `touched` map and + // `validateOnBlur` behaviour. + onBlurField: { + type: Function as PropType<(fieldName: string) => void>, + default: undefined, + }, }, setup(props) { @@ -77,6 +85,12 @@ const MultiFieldInput = defineComponent({ // per-property dependency tracking lets each FieldInput re-render only // when the specific key it reads actually changes. const data = reactive({}); + const touchedFields = reactive>({}); + + function handleBlurField(key: string) { + touchedFields[key] = true; + props.onBlurField?.(key); + } watch( () => props.properties, @@ -240,7 +254,9 @@ const MultiFieldInput = defineComponent({ fieldDescription: f, renderInfos: data, rootData: props.rootData ?? data, + touched: Boolean(touchedFields[f.name]), onValueChangeField: handleValueChange, + onBlurField: handleBlurField, }) ); diff --git a/packages/vue/test/MultiFieldInputBlur.test.ts b/packages/vue/test/MultiFieldInputBlur.test.ts new file mode 100644 index 0000000..aeccba4 --- /dev/null +++ b/packages/vue/test/MultiFieldInputBlur.test.ts @@ -0,0 +1,78 @@ +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { fieldRegistry } from '@dynamic-field-kit/core'; +import { mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, PropType } from 'vue'; +import MultiFieldInput from '../src/components/MultiFieldInput'; +import '../src/layout/defaultLayouts'; + +declare module '@dynamic-field-kit/core' { + interface FieldTypeMap { + text: string; + } +} + +const fields: FieldDescription[] = [ + { name: 'first', type: 'text', label: 'First' }, + { name: 'second', type: 'text', label: 'Second' }, +]; + +const TextRenderer = defineComponent({ + name: 'TextRenderer', + props: { + value: null, + id: String, + onBlur: Function as PropType<() => void>, + 'onUpdate:value': Function as PropType<(v: unknown) => void>, + }, + setup(props) { + return () => + h('input', { + 'data-testid': props.id, + value: (props.value as string) ?? '', + onBlur: props.onBlur, + }); + }, +}); + +describe('MultiFieldInput blur reporting (Vue)', () => { + beforeEach(() => { + fieldRegistry.register('text', TextRenderer); + }); + + it('reports which field was blurred', async () => { + const onBlurField = vi.fn(); + const wrapper = mount(MultiFieldInput, { + props: { fieldDescriptions: fields, onBlurField }, + }); + + await wrapper.find('[data-testid="dfk-field-second"]').trigger('blur'); + + expect(onBlurField).toHaveBeenCalledWith('second'); + }); + + it('reports each field separately', async () => { + const onBlurField = vi.fn(); + const wrapper = mount(MultiFieldInput, { + props: { fieldDescriptions: fields, onBlurField }, + }); + + await wrapper.find('[data-testid="dfk-field-first"]').trigger('blur'); + await wrapper.find('[data-testid="dfk-field-second"]').trigger('blur'); + + expect(onBlurField.mock.calls.map(([name]) => name)).toEqual([ + 'first', + 'second', + ]); + }); + + it('works without a handler', async () => { + const wrapper = mount(MultiFieldInput, { + props: { fieldDescriptions: fields }, + }); + + await expect( + wrapper.find('[data-testid="dfk-field-first"]').trigger('blur') + ).resolves.not.toThrow(); + }); +}); From 95f866e67aa8a7a13fd40c071e35bf24aad0a2da Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 23:20:13 +0700 Subject: [PATCH 19/20] docs: document the v1.4 APIs and add a runnable wizard demo Why: The v1.4 features were a bullet list with no examples, and several shipped APIs appeared nowhere in the README at all - the wizard navigation, the group array helpers, defaultRenderersMap/getDefaultRenderer, and the blur wiring. The built-in renderer list was also stale: it named 7 types when the packages ship 14. What: - Sections with real examples for form state, the wizard, DevTools and the group array helpers, each with a table of the exported surface. - Correct the renderer list, and document what each default emits. - A "Runnable Examples" section mapping each demo page to what it shows. - New /wizard page in the react example: step indicator driven by completedSteps, per-step validateStep, goNext/goPrev. Linked from the other two pages. Every identifier named in the README was checked against the built dist, and the example app compiles in CI. How to test: cd example/react-app && npm run dev # then visit /wizard --- .changeset/lucky-pugs-shake.md | 6 +- README.md | 211 ++++++++++++++- example/react-app/app/new-features/page.tsx | 12 +- example/react-app/app/page.tsx | 11 + example/react-app/app/wizard/page.tsx | 273 ++++++++++++++++++++ 5 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 example/react-app/app/wizard/page.tsx diff --git a/.changeset/lucky-pugs-shake.md b/.changeset/lucky-pugs-shake.md index 49e10ec..3e5e0a9 100644 --- a/.changeset/lucky-pugs-shake.md +++ b/.changeset/lucky-pugs-shake.md @@ -11,12 +11,14 @@ Add form state hooks, schema adapters, wizard engine, DevTools and extended rend - `zodValidator`, `yupValidator`, `valibotValidator` / `standardSchemaValidator`. Adapters parse **synchronously** so their result is usable by the synchronous `validateFields`; schemas with async refinements or async `.test()` rules return a Promise and must be validated through `validateFieldsAsync`. - Adapters take an explicit `{ target: 'form' | 'field' }` option. `'form'` (the default) parses the form data object; `'field'` parses a single scalar value. The field-name shorthand — `zodValidator(schema, 'email')` — is unchanged. -- Wizard engine: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`. +- Wizard engine: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`, plus the navigation the engine needs to be usable - `goNext`, `goPrev`, `goToStep`, `markStepCompleted`, `isStepCompleted`. `goNext` records the step it leaves, so `completedSteps` is actually maintained. - Group array helpers: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, `focusFirstInvalidField`. +- `switch` is a first-class field type: it had a shipped renderer in react and vue but no `FieldTypeMap` entry, so `type: 'switch'` did not typecheck. **React / Vue / Angular** - `useDynamicForm` (React, Vue) and `createDynamicFormStore` (Angular Signals) now expose the same surface, including `isSubmitting` and `isSubmitted`. - `handleSubmit(onValid, onInvalid)` returns a submit handler in every framework and calls `preventDefault` on the event it receives. - Default HTML5 renderers for `radio`, `range`, `file`, `date`, `time`, `datetime-local` and `switch`. -- `DynamicFormDevTools` overlay for inspecting form data, errors, metadata and field descriptions. +- `DynamicFormDevTools` overlay for inspecting form data, errors, metadata and field descriptions, with an error-count badge in all three frameworks. +- `MultiFieldInput` reports blur through `onBlurField` (an `@Output` in Angular), so a form store's `handleBlur` / `touched` / `validateOnBlur` can be wired to it. Vue and Angular previously had no blur plumbing at all. diff --git a/README.md b/README.md index eaef621..ba78445 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, b - **Form State Hook / Composable / Signal Store**: `useDynamicForm` for React & Vue 3, `createDynamicFormStore` for Angular Signals. All three expose the same surface — including `isSubmitting` / `isSubmitted` — and `handleSubmit(onValid, onInvalid)` returns a submit handler in every framework. - **Extended HTML5 Renderers**: Built-in support for `radio`, `range`, `file`, `date`, `time`, `datetime-local`, and `switch`. - **Schema Validation Adapters**: Integrated `zodValidator`, `yupValidator`, `valibotValidator`, and Standard Schema adapters. -- **Multi-Step Form Wizard Engine**: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`. +- **Multi-Step Form Wizard Engine**: `createWizardState`, `validateStep`, `canGoNext`, `canGoPrev`, `goNext`, `goPrev`, `goToStep`, `markStepCompleted`, `isStepCompleted`. State is immutable — every navigation returns a new state, and `goNext` records the step it leaves in `completedSteps`. - **Interactive Form DevTools**: Floating overlay component (``) for realtime debugging. +- **Blur wiring**: `MultiFieldInput` reports blur via `onBlurField` (an `@Output` in Angular), so a form store's `handleBlur` / `touched` / `validateOnBlur` can be connected to it. - **Group Array Manipulation Helpers**: `moveGroupItem`, `swapGroupItems`, `insertGroupItem`, and `focusFirstInvalidField`. #### Schema adapters @@ -301,10 +302,29 @@ const asyncResult = await validateFieldsAsync(fields, data); **Default Built-in HTML5 Renderers (Zero Config)** -All framework adapters (`react`, `vue`, `angular`) ship with **built-in HTML5 fallback renderers** for common input types: -`'text'`, `'number'`, `'select'`, `'checkbox'`, `'textarea'`, `'password'`, `'email'`. +All framework adapters (`react`, `vue`, `angular`) ship with **built-in HTML5 fallback renderers**: -If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states! +`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` · +`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch` + +If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states. + +Every key above is also a `FieldTypeMap` entry, so `type: 'switch'` typechecks +out of the box. The map itself is reachable if you need to wrap or inspect a +default: + +```ts +import { + defaultRenderersMap, + getDefaultRenderer, +} from '@dynamic-field-kit/react'; // or /vue + +const Base = getDefaultRenderer('date'); // undefined for an unknown type +Object.keys(defaultRenderersMap); // every built-in type key +``` + +`file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number` +emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings. **Custom Field Registry (Custom UI & Design Systems)** @@ -346,6 +366,189 @@ registry.register('text', myTextRenderer); --- +## 🧾 Form State (`useDynamicForm`) + +Holds the data, errors, touched and submission state for a set of fields. React +and Vue export `useDynamicForm`; Angular exports `createDynamicFormStore`, built +on signals. All three expose the same surface. + +```tsx +// React +import { useDynamicForm, MultiFieldInput } from '@dynamic-field-kit/react'; + +const form = useDynamicForm({ + fields, + initialValues: { country: 'VN' }, + validateOnBlur: true, // default + validateOnChange: false, // default +}); + +
save(data))}> + + +; +``` + +```ts +// Vue — same names, refs instead of plain values +const form = useDynamicForm({ fields }); +form.data.value; +form.isSubmitting.value; + +// Angular — same names, signals +const store = createDynamicFormStore({ fields }); +store.data(); +store.isSubmitting(); +``` + +| Member | Description | +| ----------------------------------- | --------------------------------------------------------------------------------- | +| `data` | Current form data, with `computeValue` fields applied | +| `errors` | `Record`, keyed like `validateFields` | +| `isValid` / `isDirty` | No errors recorded / any value has changed | +| `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted | +| `touched` | Fields that have been blurred | +| `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` | +| `setFieldValue(name, value)` | Change one field | +| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` | +| `setFieldTouched(name, value?)` | Set touched explicitly | +| `validate()` | Validate now, returns a boolean | +| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | +| `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches | + +`handleSubmit` returns a **handler** in every framework, so Angular binds it the +same way: `onSubmit = this.store.handleSubmit((data) => …)` then +`(ngSubmit)="onSubmit($event)"`. + +## 🧭 Multi-Step Wizard + +A framework-agnostic state machine over grouped `FieldDescription`s. State is +immutable — every navigation returns a new state object. + +```ts +import { + createWizardState, + validateStep, + goNext, + goPrev, + canGoNext, + isStepCompleted, +} from '@dynamic-field-kit/core'; + +const steps = [ + { id: 'account', title: 'Account', fields: accountFields }, + { id: 'profile', title: 'Profile', fields: profileFields }, +]; + +let wizard = createWizardState(steps); + +function next(data) { + // goNext does not validate — decide for yourself whether the step may be left + const { valid, errors } = validateStep(wizard.currentStep, data); + if (!valid) return errors; + wizard = goNext(wizard); // records the step it leaves in completedSteps +} +``` + +| Export | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------- | +| `createWizardState(steps, index?)` | Initial state; `index` is clamped into range | +| `validateStep(step, data)` | `{ valid, errors }` for one step's fields | +| `canGoNext` / `canGoPrev` | Whether a move is possible | +| `goNext` / `goPrev` | Move one step. Returns the **same object** when it cannot, so `===` detects a no-op | +| `goToStep(state, index)` | Jump anywhere (clamped). Does not mark anything completed | +| `markStepCompleted(state, index?)` | Record a step as done; defaults to the current one | +| `isStepCompleted(state, index)` | For rendering a step indicator | + +`WizardState` carries `currentStep`, `currentStepIndex`, `totalSteps`, +`isFirstStep`, `isLastStep`, `steps` and `completedSteps`. + +## 🛠️ DevTools + +A floating overlay showing live form data, errors, metadata and field +descriptions. Render it next to your form during development. + +```tsx +import { DynamicFormDevTools } from '@dynamic-field-kit/react'; // or /vue + +; +``` + +```html + + +``` + +The collapsed button carries a red badge with the number of fields in error. + +## 🧮 Group Array Helpers + +`MultiFieldInput` renders add/remove controls for repeatable groups on its own. +These helpers are for driving a group's array yourself — a drag handle, a +"duplicate row" button, a custom group renderer: + +```ts +import { + moveGroupItem, + swapGroupItems, + insertGroupItem, + isFieldGroup, + createGroupItem, + canAddGroupItem, + canRemoveGroupItem, + focusFirstInvalidField, +} from '@dynamic-field-kit/core'; + +const reordered = moveGroupItem(items, 3, 0); // returns the SAME array if out of range +const withRow = insertGroupItem(items, 1, createGroupItem(field)); + +if (canAddGroupItem(field, items)) { + /* respects maxItems */ +} + +// After a failed submit: focus + scroll to the first [aria-invalid="true"] field +focusFirstInvalidField(formElement); +``` + +All of them are pure — they return a new array and never mutate the input. + +--- + +## ▶️ Runnable Examples + +`example/` holds a working app per framework. They consume the packages through +`file:` paths, so build the packages first: + +```bash +npm run build # from the repo root +cd example/react-app && npm install && npm run dev +``` + +| Page | Shows | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `/` (react, vue, angular) | Registering renderers, `MultiFieldInput`, layouts, conditions, repeatable groups | +| `/new-features` (react) | `useDynamicForm`, the extended HTML5 renderers, blur wiring via `onBlurField`, `DynamicFormDevTools` | +| `/wizard` (react) | The wizard engine end to end: step indicator from `completedSteps`, per-step `validateStep`, `goNext` / `goPrev` | + +CI builds all three example apps on every PR, so the code above is guaranteed +to compile against the current packages. + +--- + ## 📖 Framework-Specific Usage For detailed setup and component API: diff --git a/example/react-app/app/new-features/page.tsx b/example/react-app/app/new-features/page.tsx index e197352..cb964aa 100644 --- a/example/react-app/app/new-features/page.tsx +++ b/example/react-app/app/new-features/page.tsx @@ -99,9 +99,19 @@ export default function NewFeaturesPage() { > ← Demo Cơ Bản (Legacy) - + ✨ Demo Enterprise Features (v1.4+) + + 🧭 Wizard nhiều bước → +

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

Dynamic Field Kit React Demo

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

+ Multi-Step Form Wizard +

+

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

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

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

+ + {/* Only the current step's fields are rendered */} + + + {Object.keys(errors).length > 0 && ( +
    + {Object.entries(errors).map(([field, messages]) => ( +
  • + {field}: {messages.join(', ')} +
  • + ))} +
+ )} + +
+ + + {wizard.isLastStep ? ( + + ) : ( + + )} +
+ + )} +
+ ); +} From 85e524339bd7a06d8b742cb570ddff6e72d29ad9 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Aug 2026 23:27:07 +0700 Subject: [PATCH 20/20] ci: publish the example apps to GitHub Pages Why: There was no link to hand someone who wants to see the library work. The example apps only ran locally, and running them means cloning the repo and building four packages first. What: - deploy-pages.yml builds all three demos and publishes them under one site: /react, /vue, /angular, plus a landing page. It runs on develop and on demand, and only when packages/ or example/ changed. - Each app takes its base path from an env var, so local dev is untouched: Next reads PAGES_BASE_PATH, Vite reads it as `base`, Angular gets --base-href on the command line. - Next now emits a static export with trailingSlash, so /wizard resolves to wizard/index.html on a plain file host. A .nojekyll file stops Pages from stripping _next. - README gains per-package npm badges and demo links. The repo's About link now points at the demos, so npm needed a home in the README. Also deletes example/react-app/next.config.ts. Next resolves next.config.js first, so the .ts file - and the `reactCompiler: true` in it - had never taken effect. Verified by adding output:'export' to the .js and watching out/ appear. And ignores example/ and smoke/ in .eslintignore. `npm run lint` only covers packages/*/src, so CI never linted them, but the pre-commit hook did - against a config written for library source, which rejects a `require` in a Next config and cannot resolve an example's own dependencies. Live at https://vannt-dev.github.io/dynamic-field-kit/ once this is on develop. How to test: Actions > Deploy demos to Pages > Run workflow --- .eslintignore | 9 ++ .github/workflows/deploy-pages.yml | 97 ++++++++++++++++ README.md | 10 ++ example/pages-index.html | 170 +++++++++++++++++++++++++++++ example/react-app/next.config.js | 14 +++ example/react-app/next.config.ts | 8 -- example/vue-app/vite.config.ts | 3 + 7 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/deploy-pages.yml create mode 100644 example/pages-index.html delete mode 100644 example/react-app/next.config.ts diff --git a/.eslintignore b/.eslintignore index fa397d0..be0b7b8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,12 @@ dist/ coverage/** .nyc_output/** *.log + +# The `lint` script only covers packages/*/src, so CI never lints the demo +# apps. Without this, the pre-commit hook lints them anyway - against a config +# written for library source - and fails on things like a `require` in +# next.config.js or an import it cannot resolve from an example's own +# node_modules. The examples are still prettier-checked, typechecked by their +# own builds, and built in CI. +example/** +smoke/** diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..e8f47fb --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,97 @@ +name: Deploy demos to Pages + +# Publishes the three example apps to GitHub Pages so there is a link to hand +# people. They consume the packages through `file:` paths, so whatever is on +# this branch is exactly what the demos are built against. +on: + push: + branches: [develop] + # Only rebuild when something the demos actually render could have changed. + paths: + - 'packages/**' + - 'example/**' + - '.github/workflows/deploy-pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time, and never cancel one midway. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + env: + # Project pages live under //, so every app needs its assets + # prefixed or they 404. + SITE_BASE: /${{ github.event.repository.name }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install workspace dependencies + run: npm ci + + - name: Build packages + run: npm run build + + - name: Build react demo + working-directory: example/react-app + env: + PAGES_BASE_PATH: ${{ env.SITE_BASE }}/react + run: | + npm install --no-audit --no-fund + npm run build + + - name: Build vue demo + working-directory: example/vue-app + env: + PAGES_BASE_PATH: ${{ env.SITE_BASE }}/vue/ + run: | + npm install --no-audit --no-fund + npm run build + + - name: Build angular demo + working-directory: example/angular-app + run: | + npm install --no-audit --no-fund + npx ng build --base-href ${{ env.SITE_BASE }}/angular/ + + - name: Assemble the site + run: | + mkdir -p _site + cp example/pages-index.html _site/index.html + cp -r example/react-app/out _site/react + cp -r example/vue-app/dist _site/vue + cp -r example/angular-app/dist/example-angular-app _site/angular + # Pages runs the output through Jekyll otherwise, which drops + # directories starting with an underscore - _next among them. + touch _site/.nojekyll + echo "--- deployed tree ---" + find _site -maxdepth 2 -name index.html + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index ba78445..6b4a552 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ # Dynamic Field Kit [![CI](https://github.com/vannt-dev/dynamic-field-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/vannt-dev/dynamic-field-kit/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/core?label=core)](https://www.npmjs.com/package/@dynamic-field-kit/core) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/react?label=react)](https://www.npmjs.com/package/@dynamic-field-kit/react) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/vue?label=vue)](https://www.npmjs.com/package/@dynamic-field-kit/vue) +[![npm](https://img.shields.io/npm/v/@dynamic-field-kit/angular?label=angular)](https://www.npmjs.com/package/@dynamic-field-kit/angular) + +**[▶ Live demos](https://vannt-dev.github.io/dynamic-field-kit/)** — the same +schema rendered by [React](https://vannt-dev.github.io/dynamic-field-kit/react/), +[Vue](https://vannt-dev.github.io/dynamic-field-kit/vue/) and +[Angular](https://vannt-dev.github.io/dynamic-field-kit/angular/), including a +[multi-step wizard](https://vannt-dev.github.io/dynamic-field-kit/react/wizard/). A lightweight, extensible **dynamic form engine** for React, Angular, and Vue, built for scalable applications and design systems. diff --git a/example/pages-index.html b/example/pages-index.html new file mode 100644 index 0000000..549a82f --- /dev/null +++ b/example/pages-index.html @@ -0,0 +1,170 @@ + + + + + + Dynamic Field Kit — live demos + + + + +
+

Dynamic Field Kit

+

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

+ + + +

What each React page shows

+
    +
  • + Basics — registering renderers, + MultiFieldInput, layouts, conditional fields, repeatable + groups +
  • +
  • + Enterprise features — + useDynamicForm, the extended HTML5 renderers, blur + wiring, + DynamicFormDevTools +
  • +
  • + Multi-step wizard — + createWizardState, per-step validateStep, + goNext/goPrev, completed-step indicator +
  • +
+ + +
+ + diff --git a/example/react-app/next.config.js b/example/react-app/next.config.js index 067c288..a142f30 100644 --- a/example/react-app/next.config.js +++ b/example/react-app/next.config.js @@ -1,11 +1,25 @@ const path = require('path'); +// Set by the Pages workflow to '/dynamic-field-kit/react'. Empty locally, so +// `next dev` and `next build` keep serving the app at the root. +const basePath = process.env.PAGES_BASE_PATH || ''; + /** @type {import('next').NextConfig} */ const nextConfig = { // Silence turbopack root warning in monorepos by explicitly setting the root turbopack: { root: path.resolve(__dirname, '../../'), }, + // Static HTML export. Every route here is already prerendered, and this is + // what lets the demo be served from GitHub Pages. + output: 'export', + basePath, + assetPrefix: basePath || undefined, + // Pages has no Next image optimizer behind it. + images: { unoptimized: true }, + // Emit `wizard/index.html` rather than `wizard.html`, so a static host + // resolves /wizard without needing a rewrite rule. + trailingSlash: true, }; module.exports = nextConfig; diff --git a/example/react-app/next.config.ts b/example/react-app/next.config.ts deleted file mode 100644 index 4e93457..0000000 --- a/example/react-app/next.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - /* config options here */ - reactCompiler: true, -}; - -export default nextConfig; diff --git a/example/vue-app/vite.config.ts b/example/vue-app/vite.config.ts index 7e34266..3618fcc 100644 --- a/example/vue-app/vite.config.ts +++ b/example/vue-app/vite.config.ts @@ -4,6 +4,9 @@ import vue from '@vitejs/plugin-vue'; // https://vite.dev/config/ export default defineConfig({ + // Set by the Pages workflow to '/dynamic-field-kit/vue/'. Defaults to '/' so + // `vite dev` and a plain `vite build` are unaffected. + base: process.env.PAGES_BASE_PATH || '/', plugins: [vue()], resolve: { alias: {