Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/defaulted-form-fields.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions docs/cache-form-migration.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions libs/react-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 1 addition & 5 deletions libs/react-form/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,7 @@ export function FormProvider<
*/
export function useField<
TSchema extends ObjectSchemaBuilder<any, any, any>,
TPropertySchema extends SchemaBuilder<any, any, any> = SchemaBuilder<
any,
any,
any
>
TPropertySchema extends SchemaBuilder<any, any, any, any> = any
>(
forProperty: (
tree: PropertyDescriptorTree<TSchema, TSchema>
Expand Down
37 changes: 37 additions & 0 deletions libs/react-form/src/declarations.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
141 changes: 141 additions & 0 deletions libs/react-form/src/defaults.test-d.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(() => null),
'string:select': defineFieldRenderer<
string,
{ onSelect?: (value: string) => void }
>(() => null),
'number:select': defineFieldRenderer<
number,
{ onSelect?: (value: number) => void }
>(() => null),
boolean: defineFieldRenderer<boolean>(() => null),
array: defineFieldRenderer<string[]>(() => null),
date: defineFieldRenderer<Date>(() => null),
'string:nullable': defineFieldRenderer<string | null>(() => 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<string | undefined>();
expectTypeOf(kind.value).toEqualTypeOf<'normal' | 'offset' | undefined>();
expectTypeOf(active.value).toEqualTypeOf<boolean | undefined>();
expectTypeOf(count.value).toEqualTypeOf<number | undefined>();
expectTypeOf(tags.value).toEqualTypeOf<string[] | undefined>();
expectTypeOf(when.value).toEqualTypeOf<Date | undefined>();
expectTypeOf(optional.value).toEqualTypeOf<string | undefined>();
expectTypeOf(nullable.value).toEqualTypeOf<string | null | undefined>();
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);
<system.Field form={form} forProperty={t => t.name} />;
<system.Field form={form} forProperty={t => t.active} />;
<system.Field form={form} forProperty={t => t.tags} />;
<system.Field form={form} forProperty={t => t.when} />;
<system.Field form={form} forProperty={t => t.kind} />;
<system.Field
form={form}
forProperty={t => t.nullable}
variant="nullable"
/>;
// @ts-expect-error nullable default requires a nullable renderer
<system.Field form={form} forProperty={t => t.nullable} />;
// @ts-expect-error numeric field cannot use a string variant
<system.Field form={form} forProperty={t => t.count} variant="nullable" />;
<FormProvider form={form}>
<Field form={form} forProperty={t => t.name} />
</FormProvider>;
useField<typeof schema>(t => t.name);
const contextField = useField<typeof schema, typeof nameSchema>(
t => t.name
);
expectTypeOf(contextField.value).toEqualTypeOf<string | undefined>();
form.handleSubmit(values => {
expectTypeOf(values.name).toEqualTypeOf<string>();
expectTypeOf(values.tags).toEqualTypeOf<string[]>();
expectTypeOf(values.active).toEqualTypeOf<boolean>();
});
const rendererSchema: FieldRenderProps<string>['schema'] = nameSchema;
expectTypeOf(rendererSchema).not.toBeNever();
});

test('same-name variants check explicitly typed callbacks against the selected property', () => {
const form = useSchemaForm(schema);
<system.Field
form={form}
forProperty={t => t.name}
variant="select"
fieldProps={{
onSelect: (value: string) => {
expectTypeOf(value).toEqualTypeOf<string>();
}
}}
/>;
<system.Field
form={form}
forProperty={t => t.count}
variant="select"
fieldProps={{
onSelect: (value: number) => {
expectTypeOf(value).toEqualTypeOf<number>();
}
}}
/>;
<system.Field
form={form}
forProperty={t => t.name}
variant="select"
// @ts-expect-error callbacks must match the selected renderer's props
fieldProps={{ onSelect: (_value: number) => {} }}
/>;
});
72 changes: 72 additions & 0 deletions libs/react-form/src/defaults.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
4 changes: 3 additions & 1 deletion libs/react-form/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export function getDescriptorPath(
/**
* Returns the schema type string (e.g. "string", "number", "object").
*/
export function getSchemaType(schema: SchemaBuilder<any, any, any>): string {
export function getSchemaType(
schema: SchemaBuilder<any, any, any, any>
): string {
const introspected = schema.introspect();
return introspected?.type ?? 'unknown';
}
Expand Down
6 changes: 3 additions & 3 deletions libs/react-form/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import type {
export type SchemaFormInstance<
TSchema extends ObjectSchemaBuilder<any, any, any>
> = {
useField: <TPropertySchema extends SchemaBuilder<any, any, any>>(
useField: <TPropertySchema extends SchemaBuilder<any, any, any, any>>(
forProperty: (
tree: PropertyDescriptorTree<TSchema, TSchema>
) => PropertyDescriptor<TSchema, TPropertySchema, any>
Expand Down Expand Up @@ -305,7 +305,7 @@ export function useSchemaForm<
formContextRef.current = formContextValue;
const _getFormContext = useCallback(() => formContextRef.current, []);
const useFieldHook = useCallback(
<TPropertySchema extends SchemaBuilder<any, any, any>>(
<TPropertySchema extends SchemaBuilder<any, any, any, any>>(
selector: (
tree: PropertyDescriptorTree<TSchema, TSchema>
) => PropertyDescriptor<TSchema, TPropertySchema, any>
Expand Down Expand Up @@ -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<any, any, any>,
schema: SchemaBuilder<any, any, any, any>,
variant?: string
): FieldRenderer | undefined {
if (!config?.renderers) return undefined;
Expand Down
2 changes: 2 additions & 0 deletions libs/react-form/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading