UNPKG

@mmstack/form-core

Version:

[![npm version](https://badge.fury.io/js/%40mmstack%2Fform-core.svg)](https://www.npmjs.com/package/@mmstack/form-core) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/form/core/LICENS

530 lines (523 loc) 21.9 kB
import { derived, isDerivation, toFakeSignalDerivation } from '@mmstack/primitives'; export { derived, isDerivation } from '@mmstack/primitives'; import { isSignal, signal, untracked, computed, linkedSignal } from '@angular/core'; /** * Merges two arrays element by element using the `mergeIfObject` logic. * * The resulting array will have the same length as the `next` array. * For each index `i`, the element in the resulting array is determined by: * `mergeIfObject(prev[i], next[i])`. * If `prev` is shorter than `next`, `prev[i]` will be `undefined` for the out-of-bounds indices. * * @template {any[]} T The array type being merged. * @param {T} prev The previous array. * @param {T} next The next array. * @returns {T} A new array containing the merged elements. * @example * const prev = [1, { id: 1, name: "A" }, [10], "extraPrev"]; * const next = [2, { id: 1, status: "B" }, [20, 21], missing , { id: 2 }]; * * mergeArray(prev, next); * // Result approx: * // [ * // 2, // Primitive replaced * // { id: 1, name: "A", status: "B" }, // Objects shallow-merged * // [20, 21], // Arrays replaced (via mergeIfObject -> mergeArray) * // undefined, // Primitive replaced by missing item in sparse 'next' array * // { id: 2 } // Added from 'next' (prev[4] was undefined) * // ] */ function mergeArray(prev, next) { return next.map((item, index) => mergeIfObject(prev[index], item)); } /** * Merges two values (`prev`, `next`), prioritizing `next` in most cases. * * Behavior: * - If both `prev` and `next` are non-null, non-array objects, it performs a **shallow merge** * (`{ ...prev, ...next }`), where properties from `next` overwrite those in `prev`. * - If both `prev` and `next` are arrays, it delegates to `mergeArray` for element-wise merging. * - In all other scenarios (type mismatch, primitives, null involved, array mixed with object), * it simply returns the `next` value. * * @template T The type of the values being merged. * @param {T | undefined} prev The previous value (can be undefined if accessed out of array bounds). * @param {T} next The next value. * @returns {T} The merged result based on the rules above. */ function mergeIfObject(prev, next) { if (typeof prev !== typeof next) return next; if (typeof prev !== 'object' || typeof next !== 'object') return next; if (prev === null || next === null) return next; if (Array.isArray(prev) && Array.isArray(next)) return mergeArray(prev, next); if (Array.isArray(prev) || Array.isArray(next)) return next; return { ...prev, ...next }; } /** * Generates a unique ID using crypto.randomUUID if available, * otherwise falls back to a non-secure random string. * @returns {string} */ function generateID() { if (globalThis.crypto?.randomUUID) { return globalThis.crypto.randomUUID(); } return Math.random().toString(36).substring(2); } /** * Creates a `FormControlSignal`, a reactive form control that holds a value and tracks its * validity, dirty state, touched state, and other metadata. * * @typeParam T - The type of the form control's value. * @typeParam TParent - The type of the parent form control's value (if this control is part of a group or array). * @typeParam TControlType - The type of the control. Defaults to `'control'`. * @typeParam TPartialValue - The type of value when patching * @param initial - The initial value of the control, or a `DerivedSignal` if this control is part of a `formGroup` or `formArray`. * @param options - Optional configuration options for the control. * @returns A `FormControlSignal` instance. * * @example * // Create a simple form control: * const name = formControl('Initial Name'); * * // Create a form control with validation: * const age = formControl(0, { * validator: () => (value) => value >= 18 ? '' : 'Must be at least 18', * }); * * // Create a derived form control (equivalent to the above, but more explicit): * const user = signal({ name: 'John Doe', age: 30 }); * const name = formControl(derived(user, { * from: (u) => u.name, * onChange: (newName) => user.update(u => ({...u, name: newName})) * })); * * // Create a form group with nested controls: * const user = signal({ name: 'John Doe', age: 30 }); * const form = formGroup(user, { * name: formControl(derived(user, 'name')), * age: formControl(derived(user, 'age')), * }) */ function formControl(initial, opt) { const value = isSignal(initial) ? initial : signal(initial, opt); const initialValue = signal(untracked(value), ...(ngDevMode ? [{ debugName: "initialValue" }] : [])); const eq = opt?.equal ?? Object.is; const dirtyEq = opt?.dirtyEquality ?? eq; const disabled = computed(() => opt?.disable?.() ?? false, ...(ngDevMode ? [{ debugName: "disabled" }] : [])); const readonly = computed(() => opt?.readonly?.() ?? false, ...(ngDevMode ? [{ debugName: "readonly" }] : [])); const dirty = computed(() => !dirtyEq(value(), initialValue()), ...(ngDevMode ? [{ debugName: "dirty" }] : [])); const touched = signal(false, ...(ngDevMode ? [{ debugName: "touched" }] : [])); const validator = computed(() => opt?.validator?.() ?? (() => ''), ...(ngDevMode ? [{ debugName: "validator" }] : [])); const error = computed(() => { if (opt?.overrideValidation) return opt.overrideValidation(); if (disabled() || readonly()) return ''; return validator()(value()); }, ...(ngDevMode ? [{ debugName: "error" }] : [])); const markAsTouched = () => { touched.set(true); opt?.onTouched?.(); }; const markAllAsTouched = markAsTouched; const markAsPristine = () => touched.set(false); const markAllAsPristine = markAsPristine; const label = computed(() => opt?.label?.() ?? '', ...(ngDevMode ? [{ debugName: "label" }] : [])); const partialValue = computed(() => (dirty() ? value() : undefined), ...(ngDevMode ? [{ debugName: "partialValue" }] : [])); const internalReconcile = (newValue, force = false) => { const isDirty = untracked(dirty); if (!isDirty || force) { // very dangerous use of untracked here, don't do this everywhere :) // thanks to u/synalx for the idea to use untracked here untracked(() => { initialValue.set(newValue); value.set(newValue); }); } }; const pending = computed(() => opt?.pending?.() ?? false, ...(ngDevMode ? [{ debugName: "pending" }] : [])); return { id: opt?.id?.() ?? generateID(), value, dirty, touched, error, label, required: computed(() => opt?.required?.() ?? false), disabled, readonly, pending, valid: computed(() => !pending() && !error()), hint: computed(() => opt?.hint?.() ?? ''), markAsTouched, markAllAsTouched, markAsPristine, markAllAsPristine, from: (isSignal(initial) ? initial.from : undefined), reconcile: (newValue) => internalReconcile(newValue), forceReconcile: (newValue) => internalReconcile(newValue, true), reset: () => { opt?.onReset?.(); value.set(untracked(initialValue)); }, resetWithInitial: (initial) => { opt?.onReset?.(); initialValue.set(initial); value.set(initial); }, equal: eq, controlType: (opt?.controlType ?? 'control'), partialValue: partialValue, }; } function createReconcileChildren(factory, opt) { return (length, source, prev) => { if (!prev) { const nextControls = []; for (let i = 0; i < length; i++) { nextControls.push(factory(derived(source, i, { equal: opt?.equal }), i)); } return nextControls; } if (length === prev.length) return prev; const next = [...prev]; if (length < prev.length) { next.splice(length); } else if (length > prev.length) { for (let i = prev.length; i < length; i++) { next.push(factory(derived(source, i, { equal: opt?.equal }), i)); } } return next; }; } function formArray(initial, factory, opt) { const eq = opt?.equal ?? Object.is; const arrayEqual = (a, b) => { if (a.length !== b.length) return false; if (!a.length) return true; return a.every((v, i) => eq(v, b[i])); }; const min = computed(() => opt?.min?.() ?? 0, ...(ngDevMode ? [{ debugName: "min" }] : [])); const max = computed(() => opt?.max?.() ?? Number.MAX_SAFE_INTEGER, ...(ngDevMode ? [{ debugName: "max" }] : [])); const arrayOptions = { ...opt, equal: arrayEqual, dirtyEquality: (a, b) => a.length === b.length, controlType: 'array', }; const ctrl = formControl(initial, arrayOptions); const length = computed(() => ctrl.value().length, ...(ngDevMode ? [{ debugName: "length" }] : [])); const reconcileChildren = createReconcileChildren(factory, { equal: eq }); // linkedSignal used to re-use previous value so that only length changes are affected and existing controls are kept, but updated const children = linkedSignal({ ...(ngDevMode ? { debugName: "children" } : {}), source: () => length(), computation: (len, prev) => reconcileChildren(len, ctrl.value, prev?.value) }); const ownError = computed(() => ctrl.error(), ...(ngDevMode ? [{ debugName: "ownError" }] : [])); const error = computed(() => { const own = ownError(); if (own) return own; if (!children().length) return ''; return children() .map((c, idx) => (c.error() ? `${idx}: ${c.error()}` : '')) .filter(Boolean) .join('\n'); }, ...(ngDevMode ? [{ debugName: "error" }] : [])); const dirty = computed(() => { if (ctrl.dirty()) return true; if (!children().length) return false; return children().some((c) => c.dirty()); }, ...(ngDevMode ? [{ debugName: "dirty" }] : [])); const markAllAsTouched = () => { ctrl.markAllAsTouched(); for (const c of untracked(children)) { c.markAllAsTouched(); } }; const markAllAsPristine = () => { ctrl.markAllAsPristine(); for (const c of untracked(children)) { c.markAllAsPristine(); } }; const toPartialValue = opt?.toPartialValue ?? ((v) => v); const partialValue = computed(() => { if (!dirty()) return undefined; return children().map((c) => { const pv = c.partialValue(); if (pv) return pv; if (c.controlType === 'control') return undefined; // return full value for child objects/arrays as this cannot be partially patched without idx return toPartialValue(c.value()); }); }, ...(ngDevMode ? [{ debugName: "partialValue" }] : [])); const touched = computed(() => ctrl.touched() || !!(children().length && children().some((c) => c.touched())), ...(ngDevMode ? [{ debugName: "touched" }] : [])); const reconcile = (newValue) => { const ctrls = untracked(children); for (let i = 0; i < newValue.length; i++) { ctrls.at(i)?.reconcile(newValue[i]); // reconcile existing controls that are relevant addition/removal will be handled after ctrl.reconcile through linkedSignal } ctrl.reconcile(mergeArray(newValue, untracked(ctrl.value))); }; const forceReconcile = (newValue) => { const ctrls = untracked(children); for (let i = 0; i < newValue.length; i++) { ctrls.at(i)?.forceReconcile(newValue[i]); } ctrl.forceReconcile(newValue); }; const childrenValid = computed(() => { if (!children().length) return true; return children().every((d) => d.valid()); }, ...(ngDevMode ? [{ debugName: "childrenValid" }] : [])); const childrenPending = computed(() => !!children().length && children().some((d) => d.pending()), ...(ngDevMode ? [{ debugName: "childrenPending" }] : [])); return { ...ctrl, ownError, error, valid: computed(() => ctrl.valid() && childrenValid()), pending: computed(() => ctrl.pending() || childrenPending()), touched, children, dirty, markAllAsTouched, markAllAsPristine, min, max, partialValue, canAdd: computed(() => !ctrl.disabled() && !ctrl.readonly() && length() < max()), canRemove: computed(() => !ctrl.disabled() && !ctrl.readonly() && length() > min()), reconcile, forceReconcile, reset: () => { for (const c of untracked(children)) { c.reset(); } ctrl.reset(); }, resetWithInitial: (initial) => { const ctrls = untracked(children); for (let i = 0; i < initial.length; i++) { ctrls.at(i)?.resetWithInitial(initial[i]); } ctrl.resetWithInitial(initial); }, push: (next) => ctrl.value.update((cur) => [...cur, next]), remove: (idx) => ctrl.value.update((cur) => cur.filter((_, i) => i !== idx)), }; } /** * Creates a `FormGroupSignal`, which aggregates a set of child form controls into a single object. * * @typeParam T - The type of the form group's value (an object). * @typeParam TDerivations - A record where keys are the names of the child controls and values are the `FormControlSignal` instances. * @typeParam TParent - The type of the parent form control's value (if this group is nested within another group or array). * @param initial - The initial value of the form group (or a `WritableSignal` or `DerivedSignal` if the group is nested). * @param providedChildren - An object containing the child `FormControlSignal` instances, or a function that returns such an object. * Using a function allows for dynamic creation of child controls (e.g., in response to changes in other signals). * @param options - Optional configuration options for the form group. * @returns A `FormGroupSignal` instance. * * @example * // Create a simple form group: * const user = signal({ name: 'John Doe', age: 30 }); * const form = formGroup(user, { * name: formControl(derived(user, 'name')), * age: formControl(derived(user, 'age')), * }) * * // Create a nested form group: * const user = signal({ name: 'John', age: 30, address: {street: "Some street"} }); * * const address = derived(user, 'address'); * const userForm = formGroup(user, { * name: formControl(derived(user, 'name')), * age: formControl(derived(user, 'age')), * address: formGroup(address, { * street: formControl(derived(address, (address) => address.street), { * validator: () => (value) => value ? "" : "required!" * }) // you can create deeply nested structures. * }) * }); * * // Create a form group with dynamically created children replaced rare FormRecord requirements. * const showAddress = signal(false); * type Characteristic = { * valueType: 'string'; * value: string; * } | { * valueType: 'number'; * value: number; * } * const char = signal<Characteristic>({ valueType: 'string', value: '' }); * const charForm = formGroup(char, () => { * if (char().valueType === 'string) return createStringControl(char); * return createNumberControl(char); * }); * */ function formGroup(initial, providedChildren, opt) { const valueSignal = isSignal(initial) ? initial : signal(initial); // we fake a derivation if not present, so that .from is present on the signal const value = isDerivation(valueSignal) ? valueSignal : toFakeSignalDerivation(valueSignal); // we allow for a function/signal to be passed, this case should only be used if the child controls change dependent upon something, say if a formControl is flipped into a formGroup. const children = typeof providedChildren === 'function' ? computed(() => providedChildren()) : computed(() => providedChildren); // array allows for easier handling const derivationsArray = computed(() => Object.values(children()), ...(ngDevMode ? [{ debugName: "derivationsArray" }] : [])); const childrenDirty = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.dirty()), ...(ngDevMode ? [{ debugName: "childrenDirty" }] : [])); // by default dont compare object references, just use childrenDirtySignal const baseDirtyEq = opt?.dirtyEquality ?? opt?.equal ?? (() => true); // function which calls a signal becomes a signal const dirtyEquality = (a, b) => { return baseDirtyEq(a, b) && !childrenDirty(); }; // group control const ctrl = formControl(value, { ...opt, dirtyEquality, controlType: 'group', readonly: () => { // readonly if is readonly or all children are readonly if (opt?.readonly?.()) return true; return (!!derivationsArray().length && derivationsArray().every((d) => d.readonly())); }, disable: () => { if (opt?.disable?.()) return true; return (!!derivationsArray().length && derivationsArray().every((d) => d.disabled())); }, }); const childrenTouched = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.touched()), ...(ngDevMode ? [{ debugName: "childrenTouched" }] : [])); const touched = computed(() => ctrl.touched() || childrenTouched(), ...(ngDevMode ? [{ debugName: "touched" }] : [])); const childError = computed(() => { if (!derivationsArray().length) return ''; return derivationsArray() .map((d) => (d.error() ? `${d.label()}: ${d.error()}` : '')) .filter(Boolean) .join('\n'); }, ...(ngDevMode ? [{ debugName: "childError" }] : [])); const error = computed(() => { const ownError = ctrl.error(); if (ownError) return ownError; return childError() ? 'INVALID' : ''; }, ...(ngDevMode ? [{ debugName: "error" }] : [])); const markAllAsTouched = () => { ctrl.markAllAsTouched(); for (const ctrl of untracked(derivationsArray)) { ctrl.markAllAsTouched(); } }; const markAllAsPristine = () => { ctrl.markAllAsPristine(); for (const ctrl of untracked(derivationsArray)) { ctrl.markAllAsPristine(); } }; const reconcile = (newValue) => { // set the children values based on the derivation of the new value for (const ctrl of untracked(derivationsArray)) { const from = ctrl.from; if (!from) continue; ctrl.reconcile(from(newValue)); } ctrl.reconcile(mergeIfObject(newValue, untracked(value))); }; const forceReconcile = (newValue) => { for (const ctrl of untracked(derivationsArray)) { const from = ctrl.from; if (!from) continue; ctrl.forceReconcile(from(newValue)); } ctrl.forceReconcile(newValue); }; const createBaseValueFn = opt?.createBasePartialValue; const basePartialValue = createBaseValueFn ? computed(() => createBaseValueFn(ctrl.value())) : computed(() => ({})); const partialValue = computed(() => { const obj = { ...basePartialValue(), }; if (!ctrl.dirty()) return obj; for (const [key, ctrl] of Object.entries(children())) { const pv = ctrl.partialValue(); if (pv === undefined) continue; obj[key] = pv; } return obj; }, ...(ngDevMode ? [{ debugName: "partialValue" }] : [])); const childrenValid = computed(() => { if (!derivationsArray().length) return true; return derivationsArray().every((d) => d.valid()); }, ...(ngDevMode ? [{ debugName: "childrenValid" }] : [])); const childrenPending = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.pending()), ...(ngDevMode ? [{ debugName: "childrenPending" }] : [])); return { ...ctrl, children, partialValue, reconcile, forceReconcile, ownError: ctrl.error, touched, error, valid: computed(() => ctrl.valid() && childrenValid()), pending: computed(() => ctrl.pending() || childrenPending()), markAllAsPristine, markAllAsTouched, reset: () => { for (const ctrl of untracked(derivationsArray)) { ctrl.reset(); } ctrl.reset(); }, resetWithInitial: (initial) => { for (const ctrl of untracked(derivationsArray)) { const from = ctrl.from; if (from) ctrl.resetWithInitial(from(initial)); else ctrl.reset(); } ctrl.resetWithInitial(initial); }, }; } /** * Generated bundle index. Do not edit. */ export { formArray, formControl, formGroup }; //# sourceMappingURL=mmstack-form-core.mjs.map