From 0d480e988da6ff2663785d849125dab2cc4a482a Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 8 Sep 2026 16:01:18 +0000 Subject: [PATCH] fix(react-form): support defaulted fields and declaration-safe exports --- .changeset/defaulted-form-fields.md | 5 + docs/cache-form-migration.md | 29 ++++ libs/react-form/README.md | 11 ++ libs/react-form/src/components.tsx | 6 +- libs/react-form/src/declarations.test.ts | 37 +++++ libs/react-form/src/defaults.test-d.tsx | 141 ++++++++++++++++++ libs/react-form/src/defaults.test.tsx | 72 +++++++++ libs/react-form/src/helpers.ts | 4 +- libs/react-form/src/hooks.ts | 6 +- libs/react-form/src/index.ts | 2 + libs/react-form/src/system.tsx | 19 ++- libs/react-form/src/types.ts | 4 +- .../test-fixtures/exported-system.tsx | 18 +++ 13 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 .changeset/defaulted-form-fields.md create mode 100644 libs/react-form/src/declarations.test.ts create mode 100644 libs/react-form/src/defaults.test-d.tsx create mode 100644 libs/react-form/src/defaults.test.tsx create mode 100644 libs/react-form/test-fixtures/exported-system.tsx diff --git a/.changeset/defaulted-form-fields.md b/.changeset/defaulted-form-fields.md new file mode 100644 index 00000000..bf51a1e8 --- /dev/null +++ b/.changeset/defaulted-form-fields.md @@ -0,0 +1,5 @@ +--- +'@cleverbrush/react-form': patch +--- + +Accept defaulted schema properties in typed Field, headless useField, and renderer schema bounds without losing value inference. Return a named TypedFormSystem so shared UI packages can export inferred registries while emitting declarations. diff --git a/docs/cache-form-migration.md b/docs/cache-form-migration.md index 5dd9e674..7f767dcc 100644 --- a/docs/cache-form-migration.md +++ b/docs/cache-form-migration.md @@ -224,3 +224,32 @@ Use `boolean` for checkboxes, `string[]` for multi-selects, `number | undefined` for numeric inputs that can be cleared, and `string | null` for nullable strings. Values can initially be `undefined`; optional/nested fields retain their types. No UI-kit dependency or application-specific field vocabulary is introduced. + +### Defaulted properties and shared UI packages + +`form.useField()` and typed `Field` also support properties such as +`string().default('Untitled')`, `boolean().default(true)`, and +`array(string()).default(() => [])`. The default flag does not change which +renderer value types are compatible. Nullable fields still require nullable +renderers, enum setters still accept only their literals, and arrays retain +their element type. Defaults run during validation; initialize visible fields +with `reset(values)`. A bare `reset()` continues to clear values. + +An inferred registry and its field component can be exported from a shared +package compiled with `declaration: true`: + +```tsx +export const SharedFormSystem = createFormSystem({ + renderers: { string: text } +}); +export const SchemaField = SharedFormSystem.Field; +``` + +The public `TypedFormSystem` and `TypedFieldComponent` names keep these +exports declaration-safe; no consumer type assertion is required. + +When different value kinds reuse the same variant name (such as +`string:select` and `number:select`), TypeScript can require explicit parameter +types for inline callbacks in `fieldProps`. Annotate those parameters or pass +an already typed handler; the selected renderer still checks its callback +signature and rejects incompatible props. diff --git a/libs/react-form/README.md b/libs/react-form/README.md index 32e40d9c..25e8fc36 100644 --- a/libs/react-form/README.md +++ b/libs/react-form/README.md @@ -58,6 +58,17 @@ For typed renderer props/variants and managed submission, see the new [consumer examples and migration guide](../../docs/cache-form-migration.md). The provider-based APIs below remain supported. +Typed fields and headless `form.useField()` accept properties with schema +defaults, including enums, booleans, arrays, and nullable values. A default +does not erase the property's inferred type. Defaults are applied during +validation; call `form.reset(values)` to establish a visible clean baseline. +`reset()` still clears the store rather than restoring schema defaults. + +Shared UI packages can directly export `createFormSystem(...)` results and +`system.Field` while emitting TypeScript declarations. The named +`TypedFormSystem` and `TypedFieldComponent` types preserve the registry's +field/variant/props checks across package boundaries. + ```tsx import { object, string, number } from '@cleverbrush/schema'; import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form'; diff --git a/libs/react-form/src/components.tsx b/libs/react-form/src/components.tsx index 517c387d..1922fa0d 100644 --- a/libs/react-form/src/components.tsx +++ b/libs/react-form/src/components.tsx @@ -114,11 +114,7 @@ export function FormProvider< */ export function useField< TSchema extends ObjectSchemaBuilder, - TPropertySchema extends SchemaBuilder = SchemaBuilder< - any, - any, - any - > + TPropertySchema extends SchemaBuilder = any >( forProperty: ( tree: PropertyDescriptorTree diff --git a/libs/react-form/src/declarations.test.ts b/libs/react-form/src/declarations.test.ts new file mode 100644 index 00000000..32f9d9c1 --- /dev/null +++ b/libs/react-form/src/declarations.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node + +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { expect, test } from 'vitest'; + +test('consumers can emit declarations for exported inferred form systems', () => { + const fixture = fileURLToPath( + new URL('../test-fixtures/exported-system.tsx', import.meta.url) + ); + const options: ts.CompilerOptions = { + declaration: true, + emitDeclarationOnly: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX + }; + const host = ts.createCompilerHost(options); + const output: string[] = []; + host.writeFile = (_fileName, content) => { + output.push(content); + }; + const program = ts.createProgram([fixture], options, host); + const diagnostics = [ + ...ts.getPreEmitDiagnostics(program), + ...program.emit().diagnostics + ]; + expect( + diagnostics.map(diagnostic => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ) + ).toEqual([]); + expect(output.join('\n')).toContain('TypedFormSystem'); +}); diff --git a/libs/react-form/src/defaults.test-d.tsx b/libs/react-form/src/defaults.test-d.tsx new file mode 100644 index 00000000..6b00e011 --- /dev/null +++ b/libs/react-form/src/defaults.test-d.tsx @@ -0,0 +1,141 @@ +import { + array, + boolean, + date, + enumOf, + number, + object, + string +} from '@cleverbrush/schema'; +import { expectTypeOf, test } from 'vitest'; +import { + createFormSystem, + defineFieldRenderer, + Field, + type FieldRenderProps, + FormProvider, + useField, + useSchemaForm +} from './index.js'; + +const nameSchema = string().default('Untitled'); +const schema = object({ + name: nameSchema, + kind: enumOf('normal', 'offset').optional().default('normal'), + active: boolean().default(true), + count: number().default(1), + tags: array(string()).default(() => []), + when: date().default(() => new Date()), + optional: string().optional(), + nullable: string().nullable().default('Fallback') +}); +const system = createFormSystem({ + renderers: { + string: defineFieldRenderer(() => null), + 'string:select': defineFieldRenderer< + string, + { onSelect?: (value: string) => void } + >(() => null), + 'number:select': defineFieldRenderer< + number, + { onSelect?: (value: number) => void } + >(() => null), + boolean: defineFieldRenderer(() => null), + array: defineFieldRenderer(() => null), + date: defineFieldRenderer(() => null), + 'string:nullable': defineFieldRenderer(() => null) + } +}); + +test('defaulted properties retain their values in headless and rendered fields', () => { + const form = useSchemaForm(schema); + const name = form.useField(t => t.name); + const kind = form.useField(t => t.kind); + const active = form.useField(t => t.active); + const count = form.useField(t => t.count); + const tags = form.useField(t => t.tags); + const when = form.useField(t => t.when); + const optional = form.useField(t => t.optional); + const nullable = form.useField(t => t.nullable); + expectTypeOf(name.value).toEqualTypeOf(); + expectTypeOf(kind.value).toEqualTypeOf<'normal' | 'offset' | undefined>(); + expectTypeOf(active.value).toEqualTypeOf(); + expectTypeOf(count.value).toEqualTypeOf(); + expectTypeOf(tags.value).toEqualTypeOf(); + expectTypeOf(when.value).toEqualTypeOf(); + expectTypeOf(optional.value).toEqualTypeOf(); + expectTypeOf(nullable.value).toEqualTypeOf(); + name.setValue('New'); + kind.setValue('offset'); + active.setValue(false); + tags.setValue(['new']); + nullable.setValue(null); + // @ts-expect-error defaults must not erase the property's value type + name.setValue(1); + // @ts-expect-error enum remains narrow + kind.setValue('invalid'); + // @ts-expect-error array element types remain checked + tags.setValue([1]); + // @ts-expect-error default does not make a non-nullable string nullable + name.setValue(null); + t.name} />; + t.active} />; + t.tags} />; + t.when} />; + t.kind} />; + t.nullable} + variant="nullable" + />; + // @ts-expect-error nullable default requires a nullable renderer + t.nullable} />; + // @ts-expect-error numeric field cannot use a string variant + t.count} variant="nullable" />; + + t.name} /> + ; + useField(t => t.name); + const contextField = useField( + t => t.name + ); + expectTypeOf(contextField.value).toEqualTypeOf(); + form.handleSubmit(values => { + expectTypeOf(values.name).toEqualTypeOf(); + expectTypeOf(values.tags).toEqualTypeOf(); + expectTypeOf(values.active).toEqualTypeOf(); + }); + const rendererSchema: FieldRenderProps['schema'] = nameSchema; + expectTypeOf(rendererSchema).not.toBeNever(); +}); + +test('same-name variants check explicitly typed callbacks against the selected property', () => { + const form = useSchemaForm(schema); + t.name} + variant="select" + fieldProps={{ + onSelect: (value: string) => { + expectTypeOf(value).toEqualTypeOf(); + } + }} + />; + t.count} + variant="select" + fieldProps={{ + onSelect: (value: number) => { + expectTypeOf(value).toEqualTypeOf(); + } + }} + />; + t.name} + variant="select" + // @ts-expect-error callbacks must match the selected renderer's props + fieldProps={{ onSelect: (_value: number) => {} }} + />; +}); diff --git a/libs/react-form/src/defaults.test.tsx b/libs/react-form/src/defaults.test.tsx new file mode 100644 index 00000000..5eafcc24 --- /dev/null +++ b/libs/react-form/src/defaults.test.tsx @@ -0,0 +1,72 @@ +import { array, boolean, enumOf, object, string } from '@cleverbrush/schema'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { useSchemaForm } from './index.js'; + +afterEach(cleanup); +const schema = object({ + name: string().default('Untitled'), + kind: enumOf('normal', 'offset').optional().default('normal'), + active: boolean().default(true), + tags: array(string()).default(() => []) +}); + +test('validation applies schema defaults to submitted values without pre-filling the form store', async () => { + const { result } = renderHook(() => useSchemaForm(schema)); + expect(result.current.getValue()).toEqual({}); + const save = vi.fn(); + await act(async () => { + await result.current.handleSubmit(save)(); + }); + expect(save).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledWith({ + name: 'Untitled', + kind: 'normal', + active: true, + tags: [] + }); + expect(result.current.submitting).toBe(false); + expect(result.current.error).toBeUndefined(); +}); + +test('headless defaulted properties remain synchronized through reset and submission', async () => { + const { result } = renderHook(() => { + const form = useSchemaForm(schema); + return { + form, + name: form.useField(t => t.name), + kind: form.useField(t => t.kind), + active: form.useField(t => t.active), + tags: form.useField(t => t.tags) + }; + }); + act(() => + result.current.form.reset({ + name: 'Existing', + kind: 'offset', + active: false, + tags: ['one'] + }) + ); + expect(result.current.name.value).toBe('Existing'); + expect(result.current.kind.value).toBe('offset'); + expect(result.current.active.value).toBe(false); + expect(result.current.tags.value).toEqual(['one']); + expect(result.current.name.dirty).toBe(false); + act(() => result.current.name.onChange('Edited')); + expect(result.current.name.dirty).toBe(true); + const save = vi.fn(); + await act(async () => { + await result.current.form.handleSubmit(save)(); + }); + expect(save).toHaveBeenCalledWith({ + name: 'Edited', + kind: 'offset', + active: false, + tags: ['one'] + }); + act(() => result.current.form.reset()); + expect(result.current.name.value).toBeUndefined(); + expect(result.current.name.dirty).toBe(false); + expect(result.current.active.value).toBeUndefined(); +}); diff --git a/libs/react-form/src/helpers.ts b/libs/react-form/src/helpers.ts index 428a8875..73f35c69 100644 --- a/libs/react-form/src/helpers.ts +++ b/libs/react-form/src/helpers.ts @@ -59,7 +59,9 @@ export function getDescriptorPath( /** * Returns the schema type string (e.g. "string", "number", "object"). */ -export function getSchemaType(schema: SchemaBuilder): string { +export function getSchemaType( + schema: SchemaBuilder +): string { const introspected = schema.introspect(); return introspected?.type ?? 'unknown'; } diff --git a/libs/react-form/src/hooks.ts b/libs/react-form/src/hooks.ts index 1159433c..1d77d4e6 100644 --- a/libs/react-form/src/hooks.ts +++ b/libs/react-form/src/hooks.ts @@ -40,7 +40,7 @@ import type { export type SchemaFormInstance< TSchema extends ObjectSchemaBuilder > = { - useField: >( + useField: >( forProperty: ( tree: PropertyDescriptorTree ) => PropertyDescriptor @@ -305,7 +305,7 @@ export function useSchemaForm< formContextRef.current = formContextValue; const _getFormContext = useCallback(() => formContextRef.current, []); const useFieldHook = useCallback( - >( + >( selector: ( tree: PropertyDescriptorTree ) => PropertyDescriptor @@ -406,7 +406,7 @@ export function useFieldFromContext( /** Resolve type:variant first, falling back to the base type. */ export function resolveRenderer( config: FormSystemConfig | null, - schema: SchemaBuilder, + schema: SchemaBuilder, variant?: string ): FieldRenderer | undefined { if (!config?.renderers) return undefined; diff --git a/libs/react-form/src/index.ts b/libs/react-form/src/index.ts index 30ed2420..60aeebc1 100644 --- a/libs/react-form/src/index.ts +++ b/libs/react-form/src/index.ts @@ -19,8 +19,10 @@ export type { SchemaFormInstance } from './hooks.js'; // Hooks export { useSchemaForm } from './hooks.js'; export type { + TypedFieldComponent, TypedFieldProps, TypedFieldRenderer, + TypedFormSystem, TypedRendererRegistry } from './system.js'; export { createFormSystem, defineFieldRenderer } from './system.js'; diff --git a/libs/react-form/src/system.tsx b/libs/react-form/src/system.tsx index c54699c4..67d3a85b 100644 --- a/libs/react-form/src/system.tsx +++ b/libs/react-form/src/system.tsx @@ -65,7 +65,7 @@ type RendererChoice = { type FieldDescriptor = { readonly [SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]: { - getSchema: () => SchemaBuilder; + getSchema: () => SchemaBuilder; }; }; type DescriptorValue = InferType< @@ -86,6 +86,21 @@ export type TypedFieldProps< name?: string; } & RendererChoice>>; +/** Named callable type also supports exporting system.Field from UI packages. */ +export type TypedFieldComponent = < + TSchema extends ObjectSchemaBuilder, + TDescriptor extends FieldDescriptor +>( + props: TypedFieldProps +) => ReactNode; + +/** Named return type keeps exported consumer registries declaration-safe. */ +export type TypedFormSystem = { + Field: TypedFieldComponent; + Provider: (props: { children: ReactNode }) => ReactNode; + renderers: Readonly; +}; + /** * Create a typed Field and a provider for an application's renderer registry. * The typed Field resolves from this factory's closed registry, so an untyped @@ -96,7 +111,7 @@ export type TypedFieldProps< */ export function createFormSystem< const R extends TypedRendererRegistry ->(config: { renderers: R }) { +>(config: { renderers: R }): TypedFormSystem { const renderers = Object.freeze({ ...config.renderers }); function TypedField< TSchema extends ObjectSchemaBuilder, diff --git a/libs/react-form/src/types.ts b/libs/react-form/src/types.ts index c3e4f3f6..77f20191 100644 --- a/libs/react-form/src/types.ts +++ b/libs/react-form/src/types.ts @@ -21,7 +21,7 @@ export type FieldRenderProps> = { onChange: (value: TValue) => void; onBlur: () => void; setValue: (value: TValue) => void; - schema: SchemaBuilder; + schema: SchemaBuilder; /** * Rendering variant hint passed from the `Field` component. * Used by renderers to select a sub-variant of the base schema type @@ -107,7 +107,7 @@ export type UseFieldResult = { onChange: (value: T) => void; onBlur: () => void; setValue: (value: T) => void; - schema: SchemaBuilder; + schema: SchemaBuilder; }; /** diff --git a/libs/react-form/test-fixtures/exported-system.tsx b/libs/react-form/test-fixtures/exported-system.tsx new file mode 100644 index 00000000..e5a4704d --- /dev/null +++ b/libs/react-form/test-fixtures/exported-system.tsx @@ -0,0 +1,18 @@ +import { createFormSystem, defineFieldRenderer } from '@cleverbrush/react-form'; + +export const SharedFormSystem = createFormSystem({ + renderers: { + string: defineFieldRenderer( + () => null + ) + } +}); +export const WebFormSystem = createFormSystem({ + renderers: { + ...SharedFormSystem.renderers, + 'number:select': defineFieldRenderer( + () => null + ) + } +}); +export const SchemaField = WebFormSystem.Field;