alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
659 lines (582 loc) • 20 kB
text/typescript
import type { TArray } from "alepha";
import {
$inject,
Alepha,
coerceObject,
type Static,
type TObject,
type TSchema,
z,
} from "alepha";
import { $logger } from "alepha/logger";
import type { InputHTMLAttributes } from "react";
/**
* FormModel is a dynamic form handler that generates form inputs based on a provided TypeBox schema.
* It manages form state, handles input changes, and processes form submissions with validation.
*
* It means to be injected and used within React components to provide a structured way to create and manage forms.
*
* @see {@link useForm}
*/
export class FormModel<T extends TObject> {
protected readonly log = $logger();
protected readonly alepha = $inject(Alepha);
protected readonly values: Record<string, any> = {};
protected readonly initialValues: Record<string, any> = {};
protected submitInProgress = false;
public input: SchemaToInput<T>;
constructor(
public readonly id: string,
public readonly options: FormCtrlOptions<T>,
) {
this.options = options;
// Initialize with schema defaults first, then override with initialValues
const schemaDefaults = this.extractSchemaDefaults(options.schema);
if (Object.keys(schemaDefaults).length > 0) {
Object.assign(this.values, schemaDefaults);
}
if (options.initialValues) {
// Decode against a partial of the schema so callers can supply incomplete
// initial values (the form's whole job is to collect them). Codecs still
// run on whatever's provided; missing fields stay undefined and only the
// full schema is enforced at submit time.
const decoded = this.alepha.codec.decode(
options.schema.partial(),
options.initialValues,
) as Record<string, any>;
Object.assign(this.values, decoded);
}
this.initialValues = { ...this.values };
this.input = this.createProxyFromSchema(options, options.schema, {
store: this.values,
parent: "",
});
}
/**
* Extract default values from a zod object schema.
* Recursively handles nested objects, unwrapping optional/nullable/default.
*/
protected extractSchemaDefaults(
schema: TObject,
prefix: string = "",
): Record<string, any> {
const defaults: Record<string, any> = {};
const shape = (schema as any).shape as Record<string, any> | undefined;
if (!shape) {
return defaults;
}
for (const [key, propSchema] of Object.entries(shape)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
// Unwrap optional / nullable / default wrappers to surface a default
// value and any nested object schema.
let inner: any = propSchema;
let defaultValue: unknown;
while (inner) {
if (z.schema.isDefault(inner)) {
const dv = inner._zod.def.defaultValue;
defaultValue = typeof dv === "function" ? dv() : dv;
break;
}
if (z.schema.isOptional(inner) || z.schema.isNullable(inner)) {
inner = inner.unwrap();
continue;
}
break;
}
if (defaultValue !== undefined) {
defaults[fullKey] = defaultValue;
} else if (z.schema.isObject(inner)) {
// Recursively extract defaults from nested objects
Object.assign(
defaults,
this.extractSchemaDefaults(inner as TObject, fullKey),
);
}
}
return defaults;
}
public get currentValues(): Record<string, any> {
return this.restructureValues(this.values);
}
public get props() {
return {
id: this.id,
noValidate: true,
onSubmit: (ev?: FormEventLike) => {
ev?.preventDefault?.();
this.submit();
},
onReset: (event: FormEventLike) => this.reset(event),
};
}
public readonly setInitialValues = (values: Record<string, any>) => {
// Same partial-decode rationale as the constructor — initial values may be
// incomplete; full schema is enforced only at submit time.
const decoded = this.alepha.codec.decode(
this.options.schema.partial(),
values,
) as Record<string, any>;
// Snapshot the OLD keys before we wipe — without this, fields that
// had a value but are absent from the new initialValues never emit
// form:change and useFieldValue subscribers keep showing the stale
// value. Mirrors the union-of-keys pattern in reset() below.
const oldKeys = new Set(Object.keys(this.values));
for (const key in this.initialValues) {
delete (this.initialValues as Record<string, any>)[key];
}
Object.assign(this.initialValues, decoded);
for (const key in this.values) {
delete this.values[key];
}
Object.assign(this.values, { ...this.initialValues });
const keys = new Set<string>([...oldKeys, ...Object.keys(this.values)]);
for (const key of keys) {
const path = `/${key.replaceAll(".", "/")}`;
this.alepha.events.emit(
"form:change",
{ id: this.id, path, value: this.values[key], initial: true },
{ catch: true },
);
}
};
public readonly reset = (event?: FormEventLike) => {
event?.preventDefault?.();
// Snapshot all keys that need notification — both keys present
// before reset (so subscribers learn the cleared value) and keys
// restored from initialValues. Without the union, fields that were
// typed but absent from initialValues stay visually stale.
const keys = new Set<string>([
...Object.keys(this.values),
...Object.keys(this.initialValues),
]);
for (const key in this.values) {
delete this.values[key];
}
Object.assign(this.values, { ...this.initialValues });
for (const key of keys) {
const path = `/${key.replaceAll(".", "/")}`;
this.alepha.events.emit(
"form:change",
{ id: this.id, path, value: this.values[key] },
{ catch: true },
);
}
this.alepha.events.emit("form:reset", { id: this.id }, { catch: true });
this.options.onReset?.();
};
public readonly submit = async () => {
if (this.submitInProgress) {
this.log.warn(
"Form submission already in progress, ignoring duplicate submit.",
);
return;
}
// Lifecycle events are best-effort NOTIFICATIONS (loading spinners, toasts,
// analytics). A misbehaving listener must never break form state or reject
// submit() — hence `{ catch: true }` on every emit below. Without it, a
// throwing `react:action:error`/`form:submit:error` listener would skip the
// `form:submit:end` "loading off" signal and leave submit buttons stuck in
// their loading state forever.
await this.alepha.events.emit(
"react:action:begin",
{ type: "form", id: this.id },
{ catch: true },
);
await this.alepha.events.emit(
"form:submit:begin",
{ id: this.id },
{ catch: true },
);
this.submitInProgress = true;
const options = this.options;
try {
let values: Record<string, any> = this.restructureValues(this.values);
if (z.schema.isSchema(options.schema)) {
// HTML form controls produce strings; coerce them to the schema's
// scalar types (number/boolean) at this string boundary before strict
// decoding — otherwise a `z.number()` field would reject its "42" input.
values = this.alepha.codec.decode(
options.schema,
coerceObject(options.schema, values),
) as Record<string, any>;
}
await options.handler(values as any);
await this.alepha.events.emit(
"react:action:success",
{ type: "form", id: this.id },
{ catch: true },
);
await this.alepha.events.emit(
"form:submit:success",
{ id: this.id, values },
{ catch: true },
);
} catch (error) {
this.log.error("Form submission error:", error);
// A throwing onError callback must not abort the lifecycle either.
try {
options.onError?.(error as Error);
} catch (handlerError) {
this.log.error("Form onError handler threw:", handlerError);
}
await this.alepha.events.emit(
"react:action:error",
{ type: "form", id: this.id, error: error as Error },
{ catch: true },
);
await this.alepha.events.emit(
"form:submit:error",
{ error: error as Error, id: this.id },
{ catch: true },
);
} finally {
this.submitInProgress = false;
// The "loading off" signals live in `finally` so they ALWAYS fire,
// even if something above threw — guaranteeing the begin/end pairing
// that drives submit-button loading state.
await this.alepha.events.emit(
"react:action:end",
{ type: "form", id: this.id },
{ catch: true },
);
await this.alepha.events.emit(
"form:submit:end",
{ id: this.id },
{ catch: true },
);
}
};
/**
* Restructures flat keys like "address.city" into nested objects like { address: { city: ... } }
* Values are already typed from onChange, so no conversion is needed.
*/
protected restructureValues(store: Record<string, any>): Record<string, any> {
const values: Record<string, any> = {};
for (const [key, value] of Object.entries(store)) {
if (key.includes(".")) {
// nested object: restructure flat key to nested structure
this.restructureNestedValue(values, key, value);
} else {
// value is already typed, just copy it
values[key] = value;
}
}
return values;
}
/**
* Helper to restructure a flat key like "address.city" into nested object structure.
* The value is already typed, so we just assign it to the nested path.
*/
protected restructureNestedValue(
values: Record<string, any>,
key: string,
value: any,
) {
const pathSegments = key.split(".");
const finalPropertyKey = pathSegments.pop();
if (!finalPropertyKey) {
return;
}
let currentObjectLevel = values;
// traverse/create the nested structure
for (const segment of pathSegments) {
currentObjectLevel[segment] ??= {};
currentObjectLevel = currentObjectLevel[segment];
}
// value is already typed from onChange, just assign it
currentObjectLevel[finalPropertyKey] = value;
}
protected createProxyFromSchema<T extends TObject>(
options: FormCtrlOptions<T>,
schema: TSchema,
context: {
parent: string;
store: Record<string, any>;
},
): SchemaToInput<T> {
const parent = context.parent || "";
return new Proxy<SchemaToInput<T>>({} as SchemaToInput<T>, {
get: (_, prop: string) => {
if (!options.schema || !z.schema.isObject(schema)) {
return {};
}
if (prop in schema.properties) {
// // it's a nested object, create another proxy
// if (z.schema.isObject(schema.properties[prop])) {
// return this.createProxyFromSchema(
// options,
// schema.properties[prop],
// {
// parent: parent ? `${parent}.${prop}` : prop,
// store: context.store,
// },
// );
// }
return this.createInputFromSchema<T>(
prop as keyof Static<T> & string,
options,
schema,
z.schema.requiredKeys(schema).includes(prop as string) || false,
context,
);
}
},
});
}
protected createInputFromSchema<T extends TObject>(
name: keyof Static<T> & string,
options: FormCtrlOptions<T>,
schema: TObject,
required: boolean,
context: {
parent: string;
store: Record<string, any>;
},
): BaseInputField {
const parent = context.parent || "";
const rawField = schema.properties?.[name];
if (!rawField) {
return {
path: "",
required,
initialValue: undefined,
props: {} as InputHTMLAttributes<unknown>,
schema: schema,
set: () => {},
form: this,
};
}
// Peel optional/nullable/default wrappers so the structural guards below
// (`isObject`/`isArray`/`isString`/…, `.maxLength`, format) see the real
// schema. Optionality is tracked separately via `isRequired`/`required`.
const field = z.schema.unwrap(rawField) as typeof rawField;
const isRequired = z.schema.requiredKeys(schema).includes(name) ?? false;
const key = parent ? `${parent}.${name}` : name;
const path = `/${key.replaceAll(".", "/")}`;
const set = (value: any) => {
const typedValue = this.getValueFromInput(value, field);
context.store[key] = typedValue;
if (options.onChange) {
options.onChange(key, typedValue, context.store);
}
this.alepha.events.emit(
"form:change",
{ id: this.id, path: path, value: typedValue },
{ catch: true },
);
};
const attr: InputHTMLAttributesLike = {
name: key,
};
// Use the form's runtime id (always set — comes from `useId()` when
// no explicit `options.id` was provided). This guarantees stable
// per-field DOM ids without forcing callers to pass `id`.
attr.id = `${this.id}-${key}`;
(attr as any)["data-testid"] = attr.id;
if (z.schema.isString(field)) {
if (field.maxLength != null) {
attr.maxLength = Number(field.maxLength);
}
if (field.minLength != null) {
attr.minLength = Number(field.minLength);
}
}
if (isRequired) {
attr.required = true;
}
if ("description" in field && typeof field.description === "string") {
attr["aria-label"] = field.description;
}
if (z.schema.isInteger(field) || z.schema.isNumber(field)) {
attr.type = "number";
} else if (name === "password") {
attr.type = "password";
} else if (name === "email") {
attr.type = "email";
} else if (name === "url") {
attr.type = "url";
} else if (z.schema.isString(field)) {
if (z.schema.format(field) === "binary") {
attr.type = "file";
} else if (z.schema.format(field) === "date") {
attr.type = "date";
} else if (z.schema.format(field) === "time") {
attr.type = "time";
} else if (z.schema.format(field) === "date-time") {
attr.type = "datetime-local";
} else {
attr.type = "text";
}
} else if (z.schema.isBoolean(field)) {
attr.type = "checkbox";
}
if (options.onCreateField) {
const customAttr = options.onCreateField(name, field);
Object.assign(attr, customAttr);
}
// if type = object, add items: { [key: string]: InputField }
if (z.schema.isObject(field)) {
return {
path,
props: attr,
schema: field,
set,
form: this,
required,
initialValue: context.store[key],
items: this.createProxyFromSchema(options, field, {
parent: key,
store: context.store,
}),
} as ObjectInputField<any>;
}
// if type = array, add items: InputField[]
if (z.schema.isArray(field)) {
return {
path,
props: attr,
schema: field,
set,
form: this,
required,
initialValue: context.store[key],
items: [], // <- will be populated dynamically in the UI
} as ArrayInputField<any>;
}
return {
path,
props: attr,
schema: field,
set,
form: this,
required,
initialValue: context.store[key],
};
}
/**
* Convert an input value to the correct type based on the schema.
* Handles raw DOM values (strings, booleans from checkboxes, Files, etc.)
*/
protected getValueFromInput(input: any, schema: TSchema): any {
// Treat null/undefined as "unset" for every schema. Without this the
// string branch below stringifies them to "null"/"undefined" (and
// the date branches throw on `new Date(undefined)`), which then
// round-trips into controlled inputs as literal text — most
// visible after the Clear (X) affordance in Control sets
// value=undefined and the input promptly displays "undefined".
if (input === null || input === undefined) {
return undefined;
}
if (input instanceof File) {
// for file inputs, return the File object directly
if (z.schema.isString(schema) && z.schema.format(schema) === "binary") {
return input;
}
// for now, ignore other formats
return null;
}
if (z.schema.isBoolean(schema)) {
// Handle string representations from Select components (Yes/No dropdown)
if (input === "true") return true;
if (input === "false") return false;
if (input === "" || input === null || input === undefined)
return undefined;
// Handle actual boolean values
return !!input;
}
if (z.schema.isNumber(schema)) {
const num = Number(input);
return Number.isNaN(num) ? null : num;
}
if (z.schema.isString(schema)) {
if (z.schema.format(schema) === "date") {
return new Date(input).toISOString().slice(0, 10); // For date input
}
if (z.schema.format(schema) === "time") {
return new Date(`1970-01-01T${input}`).toISOString().slice(11, 16); // For time input
}
if (z.schema.format(schema) === "date-time") {
return new Date(input).toISOString(); // For datetime-local input
}
return String(input);
}
return input; // fallback for other types
}
}
export type SchemaToInput<T extends TObject> = {
[K in keyof T["properties"]]: InputField<T["properties"][K]>;
};
export interface FormEventLike {
preventDefault?: () => void;
stopPropagation?: () => void;
}
export type InputField<T extends TSchema> = T extends TObject
? ObjectInputField<T>
: T extends TArray<infer U>
? ArrayInputField<U>
: BaseInputField;
export interface BaseInputField {
path: string;
required: boolean;
initialValue: any;
props: InputHTMLAttributesLike;
schema: TSchema;
set: (value: any) => void;
form: FormModel<any>;
items?: any;
}
export interface ObjectInputField<T extends TObject> extends BaseInputField {
items: SchemaToInput<T>;
}
export interface ArrayInputField<T extends TSchema> extends BaseInputField {
items: Array<InputField<T>>;
}
export type InputHTMLAttributesLike = Pick<
InputHTMLAttributes<unknown>,
| "id"
| "name"
| "type"
| "value"
| "required"
| "maxLength"
| "minLength"
| "aria-label"
> & {
value?: any;
};
export type FormCtrlOptions<T extends TObject> = {
/**
* The schema defining the structure and validation rules for the form.
* This should be a TypeBox schema object.
*/
schema: T;
/**
* Callback function to handle form submission.
* This function will receive the parsed and validated form values.
*/
handler: (values: Static<T>) => unknown;
/**
* Optional initial values for the form fields.
* This can be used to pre-populate the form with existing data.
*/
initialValues?: Partial<Static<T>>;
/**
* Optional function to create custom field attributes.
* This can be used to add custom validation, styles, or other attributes.
*/
onCreateField?: (
name: keyof Static<T> & string,
schema: TSchema,
) => InputHTMLAttributes<unknown>;
/**
* If defined, this will generate a unique ID for each field, prefixed with this string.
*
* > "username" with id="form-123" will become "form-123-username".
*
* If omitted, IDs will not be generated.
*/
id?: string;
onError?: (error: Error) => void;
onChange?: (key: string, value: any, store: Record<string, any>) => void;
onReset?: () => void;
};