UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

704 lines (703 loc) 24 kB
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