alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
704 lines (703 loc) • 24 kB
JavaScript
import { $inject, $module, Alepha, TypeBoxError, coerceObject, z } from "alepha";
import { useAlepha } from "alepha/react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { $logger } from "alepha/logger";
import { useRouter, useRouterState } from "alepha/react/router";
//#region ../../src/react/form/hooks/useFormState.ts
const useFormState = (target, _events = [
"loading",
"dirty",
"error"
]) => {
const alepha = useAlepha();
const events = _events;
const [dirty, setDirty] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(void 0);
const [values, setValues] = useState(void 0);
const form = "form" in target ? target.form : target;
const path = "path" in target ? target.path : void 0;
const hasValues = events.includes("values");
const hasErrors = events.includes("error");
const hasDirty = events.includes("dirty");
const hasLoading = events.includes("loading");
useEffect(() => {
const listeners = [];
if (hasErrors || hasValues || hasDirty) listeners.push(alepha.events.on("form:change", (event) => {
if (event.id === form.id) {
if (!path || event.path === path) {
if (hasDirty) if (event.initial) setDirty(false);
else setDirty(true);
if (hasErrors) setError(void 0);
}
if (hasValues) setValues(form.currentValues);
}
}));
if (hasLoading) listeners.push(alepha.events.on("form:submit:begin", (event) => {
if (event.id === form.id) setLoading(true);
}), alepha.events.on("form:submit:end", (event) => {
if (event.id === form.id) setLoading(false);
}));
if (hasValues || hasDirty) listeners.push(alepha.events.on("form:submit:success", (event) => {
if (event.id === form.id) {
if (hasValues) setValues(event.values);
if (hasDirty) setDirty(false);
}
}));
if (hasDirty) listeners.push(alepha.events.on("form:reset", (event) => {
if (event.id === form.id) setDirty(false);
}));
if (hasErrors) listeners.push(alepha.events.on("form:submit:error", (event) => {
if (event.id === form.id) {
if (!path || event.error instanceof TypeBoxError && event.error.value.path === path) setError(event.error);
}
}));
return () => {
for (const unsub of listeners) unsub();
};
}, []);
return {
dirty,
loading,
error,
values
};
};
//#endregion
//#region ../../src/react/form/components/FormState.tsx
const FormState = (props) => {
const formState = useFormState(props.form);
return props.children({
loading: formState.loading,
dirty: formState.dirty
});
};
//#endregion
//#region ../../src/react/form/errors/FormValidationError.ts
var FormValidationError = class extends TypeBoxError {
name = "ValidationError";
constructor(options) {
super({
message: options.message,
instancePath: options.path,
params: {}
});
}
};
//#endregion
//#region ../../src/react/form/hooks/useFieldValue.ts
/**
* Hook to subscribe to a single form field's value.
* Only re-renders when this specific field changes.
*
* @returns A tuple of [value, setValue] similar to useState.
*/
const useFieldValue = (input) => {
const alepha = useAlepha();
const [value, setValue] = useState(input?.initialValue);
useEffect(() => {
if (!input?.form || !alepha.isBrowser()) return;
return alepha.events.on("form:change", (ev) => {
if (ev.id === input.form.id && ev.path === input.path) setValue(ev.value);
});
}, []);
const setFieldValue = (newValue) => {
input.set(newValue);
};
return [value, setFieldValue];
};
//#endregion
//#region ../../src/react/form/services/FormModel.ts
/**
* 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}
*/
var FormModel = class {
id;
options;
log = $logger();
alepha = $inject(Alepha);
values = {};
initialValues = {};
submitInProgress = false;
input;
constructor(id, options) {
this.id = id;
this.options = options;
this.options = options;
const schemaDefaults = this.extractSchemaDefaults(options.schema);
if (Object.keys(schemaDefaults).length > 0) Object.assign(this.values, schemaDefaults);
if (options.initialValues) {
const decoded = this.alepha.codec.decode(options.schema.partial(), options.initialValues);
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.
*/
extractSchemaDefaults(schema, prefix = "") {
const defaults = {};
const shape = schema.shape;
if (!shape) return defaults;
for (const [key, propSchema] of Object.entries(shape)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
let inner = propSchema;
let defaultValue;
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 !== void 0) defaults[fullKey] = defaultValue;
else if (z.schema.isObject(inner)) Object.assign(defaults, this.extractSchemaDefaults(inner, fullKey));
}
return defaults;
}
get currentValues() {
return this.restructureValues(this.values);
}
get props() {
return {
id: this.id,
noValidate: true,
onSubmit: (ev) => {
ev?.preventDefault?.();
this.submit();
},
onReset: (event) => this.reset(event)
};
}
setInitialValues = (values) => {
const decoded = this.alepha.codec.decode(this.options.schema.partial(), values);
const oldKeys = new Set(Object.keys(this.values));
for (const key in this.initialValues) delete this.initialValues[key];
Object.assign(this.initialValues, decoded);
for (const key in this.values) delete this.values[key];
Object.assign(this.values, { ...this.initialValues });
const keys = /* @__PURE__ */ new Set([...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 });
}
};
reset = (event) => {
event?.preventDefault?.();
const keys = /* @__PURE__ */ new Set([...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?.();
};
submit = async () => {
if (this.submitInProgress) {
this.log.warn("Form submission already in progress, ignoring duplicate submit.");
return;
}
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 = this.restructureValues(this.values);
if (z.schema.isSchema(options.schema)) values = this.alepha.codec.decode(options.schema, coerceObject(options.schema, values));
await options.handler(values);
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);
try {
options.onError?.(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
}, { catch: true });
await this.alepha.events.emit("form:submit:error", {
error,
id: this.id
}, { catch: true });
} finally {
this.submitInProgress = false;
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.
*/
restructureValues(store) {
const values = {};
for (const [key, value] of Object.entries(store)) if (key.includes(".")) this.restructureNestedValue(values, key, value);
else 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.
*/
restructureNestedValue(values, key, value) {
const pathSegments = key.split(".");
const finalPropertyKey = pathSegments.pop();
if (!finalPropertyKey) return;
let currentObjectLevel = values;
for (const segment of pathSegments) {
currentObjectLevel[segment] ??= {};
currentObjectLevel = currentObjectLevel[segment];
}
currentObjectLevel[finalPropertyKey] = value;
}
createProxyFromSchema(options, schema, context) {
context.parent;
return new Proxy({}, { get: (_, prop) => {
if (!options.schema || !z.schema.isObject(schema)) return {};
if (prop in schema.properties) return this.createInputFromSchema(prop, options, schema, z.schema.requiredKeys(schema).includes(prop) || false, context);
} });
}
createInputFromSchema(name, options, schema, required, context) {
const parent = context.parent || "";
const rawField = schema.properties?.[name];
if (!rawField) return {
path: "",
required,
initialValue: void 0,
props: {},
schema,
set: () => {},
form: this
};
const field = z.schema.unwrap(rawField);
const isRequired = z.schema.requiredKeys(schema).includes(name) ?? false;
const key = parent ? `${parent}.${name}` : name;
const path = `/${key.replaceAll(".", "/")}`;
const set = (value) => {
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,
value: typedValue
}, { catch: true });
};
const attr = { name: key };
attr.id = `${this.id}-${key}`;
attr["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 (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
})
};
if (z.schema.isArray(field)) return {
path,
props: attr,
schema: field,
set,
form: this,
required,
initialValue: context.store[key],
items: []
};
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.)
*/
getValueFromInput(input, schema) {
if (input === null || input === void 0) return;
if (input instanceof File) {
if (z.schema.isString(schema) && z.schema.format(schema) === "binary") return input;
return null;
}
if (z.schema.isBoolean(schema)) {
if (input === "true") return true;
if (input === "false") return false;
if (input === "" || input === null || input === void 0) return void 0;
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);
if (z.schema.format(schema) === "time") return (/* @__PURE__ */ new Date(`1970-01-01T${input}`)).toISOString().slice(11, 16);
if (z.schema.format(schema) === "date-time") return new Date(input).toISOString();
return String(input);
}
return input;
}
};
//#endregion
//#region ../../src/react/form/hooks/useForm.ts
/**
* Custom hook to create a form with validation and field management.
* This hook uses TypeBox schemas to define the structure and validation rules for the form.
* It provides a way to handle form submission, field creation, and value management.
*
* @example
* ```tsx
* import { z } from "alepha";
*
* const form = useForm({
* schema: z.object({
* username: z.text(),
* password: z.text(),
* }),
* handler: (values) => {
* console.log("Form submitted with values:", values);
* },
* });
*
* return (
* <form {...form.props}>
* <input {...form.input.username.props} />
* <input {...form.input.password.props} />
* <button type="submit">Submit</button>
* </form>
* );
* ```
*/
/**
* Shallow / structural equality for form initial-value objects. Form values
* are plain data (strings, numbers, arrays, plain objects) so JSON.stringify
* is both fast enough and exact. Wrapped in try/catch to fall back to
* reference equality if anything exotic sneaks in (e.g. circular refs).
*/
const stableEqual = (a, b) => {
if (a === b) return true;
if (a == null || b == null) return false;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
};
const useForm = (options, deps = []) => {
const alepha = useAlepha();
const formId = useId();
const initialValuesRef = useRef(options.initialValues);
const form = useMemo(() => {
return alepha.inject(FormModel, {
lifetime: "transient",
args: [options.id || formId, options]
});
}, deps);
useEffect(() => {
Object.assign(form.options, {
handler: options.handler,
onError: options.onError,
onChange: options.onChange,
onReset: options.onReset,
onCreateField: options.onCreateField
});
});
useEffect(() => {
if (stableEqual(initialValuesRef.current, options.initialValues)) return;
initialValuesRef.current = options.initialValues;
if (options.initialValues) form.setInitialValues(options.initialValues);
}, [options.initialValues]);
return form;
};
//#endregion
//#region ../../src/react/form/hooks/useFormQuerySync.ts
/**
* Two-way bind a `useForm` instance to the URL query params, keyed
* per-field (so the URL stays human-readable: `?status=new&zone=ops`).
*
* Direction 1 — URL → form:
* - On mount AND every time one of the watched query keys changes
* (typically via browser back/forward or external `router.push`),
* call `form.input[key].set(value)` for each listed key. Missing /
* empty params clear the field (set to `undefined`).
*
* Direction 2 — form → URL:
* - Subscribe to the form's `form:change` event for the listed keys.
* Each emission writes the current values for all listed keys to the
* URL via `router.setQueryParams` (replace-state, no history spam).
* Empty values (`undefined` / `""`) are stripped from the URL so the
* address bar stays clean.
*
* Replaces ad-hoc `localStorage` filter persistence: the URL becomes
* the canonical store, so the view is shareable via copy-paste and
* navigable via back/forward.
*
* @example
* const form = useForm({ schema: z.object({ status: z.string(), q: z.string() }) });
* useFormQuerySync(form, { keys: ["status", "q"] });
*/
const useFormQuerySync = (form, options) => {
const alepha = useAlepha();
const router = useRouter();
const state = useRouterState();
const keys = options.keys;
const querySig = keys.map((k) => {
const raw = state.query?.[k];
return typeof raw === "string" ? raw : "";
}).join("\0");
const initialized = useRef(false);
useEffect(() => {
const isFirstRun = !initialized.current;
initialized.current = true;
const values = form.currentValues;
const missingInitials = {};
for (const key of keys) {
const raw = state.query?.[key];
const next = typeof raw === "string" && raw !== "" ? raw : void 0;
const current = values[key];
if (isFirstRun && next === void 0 && current != null && current !== "") {
missingInitials[key] = String(current);
continue;
}
if ((current ?? void 0) !== next) {
const input = form.input[key];
if (input && typeof input.set === "function") input.set(next);
}
}
if (isFirstRun && Object.keys(missingInitials).length > 0) {
const merged = {};
for (const key of keys) {
const raw = state.query?.[key];
if (typeof raw === "string" && raw !== "") merged[key] = raw;
}
for (const [k, v] of Object.entries(missingInitials)) merged[k] = v;
router.setQueryParams(merged);
}
}, [querySig]);
useEffect(() => {
if (!alepha.isBrowser()) return;
return alepha.events.on("form:change", (e) => {
if (e.id !== form.id) return;
if (!keys.includes(e.path)) return;
const next = {};
const values = form.currentValues;
for (const key of keys) {
const value = values[key];
if (value != null && value !== "") next[key] = String(value);
}
router.setQueryParams(next);
});
}, [
alepha,
form,
router
]);
};
//#endregion
//#region ../../src/react/form/hooks/useFormValues.ts
/**
* Hook to subscribe to all form values.
* Re-renders on every field change — use only when needed (debug panels, live previews).
*/
const useFormValues = (form) => {
const alepha = useAlepha();
const [values, setValues] = useState(form.currentValues);
useEffect(() => {
if (!alepha.isBrowser()) return;
return alepha.events.on("form:change", (ev) => {
if (ev.id === form.id) setValues(form.currentValues);
});
}, []);
return values;
};
//#endregion
//#region ../../src/react/form/services/prettyName.ts
/**
* Converts a path or identifier string into a pretty display name.
* For paths like "/contacts/0/name", extracts just the field name "Name".
* Handles camelCase and snake_case conversion to Title Case.
*
* @example
* prettyName("/userName") // "User Name"
* prettyName("/contacts/0/email") // "Email"
* prettyName("/address/streetName") // "Street Name"
* prettyName("first_name") // "First Name"
*/
const prettyName = (name) => {
const segments = name.split("/").filter((s) => s && !/^\d+$/.test(s));
return (segments[segments.length - 1] || name.replaceAll("/", "")).replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
};
//#endregion
//#region ../../src/react/form/services/parseField.ts
/**
* Derives a {@link FieldMeta} from an `InputField` (from `useForm`) plus
* optional overrides. Pure — no React, no JSX, no UI library coupling.
*
* UI components consume this metadata to render labels, descriptions,
* error messages, icons, and validation constraints.
*/
const parseField = (input, options = {}) => {
const schema = z.schema.unwrap(input.schema);
const meta = typeof schema.meta === "function" ? schema.meta() : null;
const label = options.label ?? (typeof meta?.title === "string" ? meta.title : void 0) ?? prettyName(input.path);
const description = options.description ?? (typeof meta?.description === "string" ? meta.description : void 0);
const error = options.error instanceof TypeBoxError ? options.error.value?.message : void 0;
const isEnum = z.schema.isEnum(schema);
const isArray = z.schema.isArray(schema);
const isObject = z.schema.isObject(schema);
const isInteger = z.schema.isInteger(schema);
const isNumber = !isInteger && z.schema.isNumber(schema);
const isBoolean = z.schema.isBoolean(schema);
const type = isObject ? "object" : isArray ? "array" : isEnum ? "string" : isInteger ? "integer" : isNumber ? "number" : isBoolean ? "boolean" : z.schema.isString(schema) ? "string" : void 0;
const format = typeof z.schema.format(schema) === "string" ? z.schema.format(schema) : void 0;
const element = schema.element ?? schema.items;
const isArrayOfObjects = isArray && z.schema.isObject(element);
const name = input.props.name;
const iconHint = inferIconHint({
type,
format,
name,
isEnum,
isArray
});
const constraints = {};
if (typeof schema.minLength === "number") constraints.minLength = schema.minLength;
if (typeof schema.maxLength === "number") constraints.maxLength = schema.maxLength;
if (typeof schema.minValue === "number") constraints.minimum = schema.minValue;
if (typeof schema.maxValue === "number") constraints.maximum = schema.maxValue;
const pattern = readPattern(schema);
if (pattern) constraints.pattern = pattern;
return {
id: input.props.id,
label,
description,
error,
required: input.required,
type,
format,
isEnum,
isArray,
isObject,
isArrayOfObjects,
enum: isEnum ? z.schema.enumValues(schema) : void 0,
iconHint,
constraints,
testId: input.props["data-testid"],
schema: input.schema,
control: meta?.$control
};
};
/** Best-effort read of a `ZodString` regex pattern from its check list. */
const readPattern = (schema) => {
const checks = schema?._zod?.def?.checks;
if (!Array.isArray(checks)) return void 0;
for (const check of checks) {
const pattern = check?._zod?.def?.pattern;
if (pattern instanceof RegExp) return pattern.source;
}
};
const inferIconHint = (params) => {
const { type, format, name, isEnum, isArray } = params;
if (format === "email") return "email";
if (format === "url" || format === "uri") return "url";
if (format === "tel" || format === "phone") return "phone";
if (format === "date" || format === "date-time") return "calendar";
if (format === "time") return "clock";
if (name?.toLowerCase().includes("password")) return "password";
if (name?.toLowerCase().includes("email")) return "email";
if (name?.toLowerCase().includes("phone")) return "phone";
if (type === "boolean") return "switch";
if (type === "number" || type === "integer") return "number";
if (isEnum || isArray) return "list";
if (type === "string") return "text";
};
//#endregion
//#region ../../src/react/form/index.ts
/**
* Type-safe forms with validation.
*
* **Features:**
* - Form state management
* - TypeBox schema validation
* - Field-level error handling
* - Submit handling with loading state
* - Form reset
*
* @module alepha.react.form
*/
const AlephaReactForm = $module({ name: "alepha.react.form" });
//#endregion
export { AlephaReactForm, FormModel, FormState, FormValidationError, parseField, prettyName, useFieldValue, useForm, useFormQuerySync, useFormState, useFormValues };
//# sourceMappingURL=index.js.map