@mmstack/form-core
Version:
[](https://www.npmjs.com/package/@mmstack/form-core) [](https://github.com/mihajm/mmstack/blob/master/packages/form/core/LICENS
530 lines (523 loc) • 21.9 kB
JavaScript
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