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/LICEN

465 lines (459 loc) 17.6 kB
import { derived, isDerivation, toFakeSignalDerivation } from '@mmstack/primitives'; export { derived, isDerivation } from '@mmstack/primitives'; import { isSignal, signal, untracked, computed, linkedSignal } from '@angular/core'; import { mergeArray, values, mergeIfObject, entries } from '@mmstack/object'; import { v7 } from 'uuid'; /** * 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)); const eq = opt?.equal ?? Object.is; const dirtyEq = opt?.dirtyEquality ?? eq; const disabled = computed(() => opt?.disable?.() ?? false); const readonly = computed(() => opt?.readonly?.() ?? false); const dirty = computed(() => !dirtyEq(value(), initialValue())); const touched = signal(false); const validator = computed(() => opt?.validator?.() ?? (() => '')); const error = computed(() => { if (opt?.overrideValidation) return opt.overrideValidation(); if (disabled() || readonly()) return ''; return validator()(value()); }); const markAsTouched = () => { touched.set(true); opt?.onTouched?.(); }; const markAllAsTouched = markAsTouched; const markAsPristine = () => touched.set(false); const markAllAsPristine = markAsPristine; const label = computed(() => opt?.label?.() ?? ''); const partialValue = computed(() => (dirty() ? value() : undefined)); 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); return { id: opt?.id?.() ?? v7(), 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); const max = computed(() => opt?.max?.() ?? Number.MAX_SAFE_INTEGER); 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); 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({ source: () => length(), computation: (len, prev) => reconcileChildren(len, ctrl.value, prev?.value), }); const ownError = computed(() => ctrl.error()); 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'); }); const dirty = computed(() => { if (ctrl.dirty()) return true; if (!children().length) return false; return children().some((c) => c.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()); }); }); const touched = computed(() => ctrl.touched() || !!(children().length && children().some((c) => c.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()); }); const childrenPending = computed(() => !!children().length && children().some((d) => d.pending())); 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(() => values(children())); const childrenDirty = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.dirty())); // 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())); const touched = computed(() => ctrl.touched() || childrenTouched()); const childError = computed(() => { if (!derivationsArray().length) return ''; return derivationsArray() .map((d) => (d.error() ? `${d.label()}: ${d.error()}` : '')) .filter(Boolean) .join('\n'); }); const error = computed(() => { const ownError = ctrl.error(); if (ownError) return ownError; return childError() ? 'INVALID' : ''; }); 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 entries(children())) { const pv = ctrl.partialValue(); if (pv === undefined) continue; obj[key] = pv; } return obj; }); const childrenValid = computed(() => { if (!derivationsArray().length) return true; return derivationsArray().every((d) => d.valid()); }); const childrenPending = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.pending())); 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