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