ngx-sub-form
Version:

327 lines (319 loc) • 14.6 kB
JavaScript
import { NG_VALUE_ACCESSOR, NG_VALIDATORS, UntypedFormArray, UntypedFormGroup, UntypedFormControl } from '@angular/forms';
import { getObservableLifecycle } from 'ngx-observable-lifecycle';
import { timer, ReplaySubject, of, EMPTY, identity, merge, concat, combineLatest } from 'rxjs';
import { debounce, takeUntil, take, startWith, map, shareReplay, switchMap, delay, filter, withLatestFrom, mapTo, tap } from 'rxjs/operators';
import { cloneDeep } from 'lodash-es';
import { inject, ChangeDetectorRef } from '@angular/core';
import isEqual from 'fast-deep-equal';
function subformComponentProviders(component) {
return [
{
provide: NG_VALUE_ACCESSOR,
useExisting: component,
multi: true,
},
{
provide: NG_VALIDATORS,
useExisting: component,
multi: true,
},
];
}
const wrapAsQuote = (str) => `"${str}"`;
class MissingFormControlsError extends Error {
constructor(missingFormControls) {
super(`Attempt to update the form value with an object that doesn't contains some of the required form control keys.\nMissing: ${missingFormControls
.map(wrapAsQuote)
.join(`, `)}`);
}
}
const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = {
debounce: (time) => (obs) => obs.pipe(debounce(() => timer(time))),
};
/**
* Easily unsubscribe from an observable stream by appending `takeUntilDestroyed(this)` to the observable pipe.
* If the component already has a `ngOnDestroy` method defined, it will call this first.
*/
function takeUntilDestroyed(component) {
const { ngOnDestroy } = getObservableLifecycle(component);
return (source) => source.pipe(takeUntil(ngOnDestroy));
}
/** @internal */
function isNullOrUndefined(obj) {
return obj === null || obj === undefined;
}
/** @internal */
const patchClassInstance = (componentInstance, obj) => {
Object.entries(obj).forEach(([key, newMethod]) => {
componentInstance[key] = newMethod;
});
};
/** @internal */
const getControlValueAccessorBindings = (componentInstance) => {
const writeValue$$ = new ReplaySubject(1);
const registerOnChange$$ = new ReplaySubject(1);
const registerOnTouched$$ = new ReplaySubject(1);
const setDisabledState$$ = new ReplaySubject(1);
const controlValueAccessorPatch = {
writeValue: (obj) => {
writeValue$$.next(obj);
},
registerOnChange: (fn) => {
registerOnChange$$.next(fn);
},
registerOnTouched: (fn) => {
registerOnTouched$$.next(fn);
},
setDisabledState: (shouldDisable) => {
setDisabledState$$.next(!!shouldDisable);
},
};
patchClassInstance(componentInstance, controlValueAccessorPatch);
return {
writeValue$: writeValue$$.asObservable(),
registerOnChange$: registerOnChange$$.asObservable(),
registerOnTouched$: registerOnTouched$$.asObservable(),
setDisabledState$: setDisabledState$$.asObservable(),
};
};
const getFormGroupErrors = (formGroup) => {
const formErrors = Object.entries(formGroup.controls).reduce((acc, [key, control]) => {
if (control.errors) {
// all of FormControl, FormArray and FormGroup can have errors so we assign them first
const accumulatedGenericError = acc;
accumulatedGenericError[key] = control.errors;
}
if (control instanceof UntypedFormArray) {
// errors within an array are represented as a map
// with the index and the error
// this way, we avoid holding a lot of potential `null`
// values in the array for the valid form controls
const errorsInArray = {};
for (let i = 0; i < control.length; i++) {
const controlErrors = control.at(i).errors;
if (controlErrors) {
errorsInArray[i] = controlErrors;
}
}
if (Object.values(errorsInArray).length > 0) {
const accumulatedArrayErrors = acc;
if (!(key in accumulatedArrayErrors)) {
accumulatedArrayErrors[key] = {};
}
Object.assign(accumulatedArrayErrors[key], errorsInArray);
}
}
return acc;
}, {});
if (!formGroup.errors && !Object.values(formErrors).length) {
return null;
}
// todo remove any
return Object.assign({}, formGroup.errors ? { formGroup: formGroup.errors } : {}, formErrors);
};
function createFormDataFromOptions(options) {
const formGroup = new UntypedFormGroup(options.formControls, options.formGroupOptions);
const defaultValues = cloneDeep(formGroup.value);
const formGroupKeys = Object.keys(options.formControls);
const formControlNames = formGroupKeys.reduce((acc, curr) => {
acc[curr] = curr;
return acc;
}, {});
const formArrays = formGroupKeys.reduce((acc, key) => {
const control = formGroup.get(key);
if (control instanceof UntypedFormArray) {
acc.push({ key, control });
}
return acc;
}, []);
return { formGroup, defaultValues, formControlNames, formArrays };
}
const handleFormArrays = (formArrayWrappers, obj, createFormArrayControl) => {
if (!formArrayWrappers.length) {
return;
}
formArrayWrappers.forEach(({ key, control }) => {
const value = obj[key];
if (!Array.isArray(value)) {
return;
}
// instead of creating a new array every time and push a new FormControl
// we just remove or add what is necessary so that:
// - it is as efficient as possible and do not create unnecessary FormControl every time
// - validators are not destroyed/created again and eventually fire again for no reason
while (control.length > value.length) {
control.removeAt(control.length - 1);
}
for (let i = control.length; i < value.length; i++) {
const newControl = createFormArrayControl(key, value[i]);
if (control.disabled) {
newControl.disable();
}
control.insert(i, newControl);
}
});
};
var FormType;
(function (FormType) {
FormType["SUB"] = "Sub";
FormType["ROOT"] = "Root";
})(FormType || (FormType = {}));
const optionsHaveInstructionsToCreateArrays = (options) => !!options.createFormArrayControl;
// @todo find a better name
const isRoot = (options) => {
const opt = options;
return opt.formType === FormType.ROOT;
};
function createForm(componentInstance, options) {
const { formGroup, defaultValues, formControlNames, formArrays } = createFormDataFromOptions(options);
let isRemoved = false;
const lifecyleHooks = options.componentHooks ?? {
onDestroy: getObservableLifecycle(componentInstance).ngOnDestroy,
afterViewInit: getObservableLifecycle(componentInstance).ngAfterViewInit,
};
const changeDetectorRef = inject(ChangeDetectorRef);
lifecyleHooks.onDestroy.pipe(take(1)).subscribe(() => {
isRemoved = true;
});
// define the `validate` method to improve errors
// and support nested errors
patchClassInstance(componentInstance, {
validate: () => {
if (isRemoved)
return null;
if (formGroup.valid) {
return null;
}
return getFormGroupErrors(formGroup);
},
});
// in order to ensure the form has the correct state (and validation errors) we update the value and validity
// immediately after the first tick
const updateValueAndValidity$ = timer(0);
const componentHooks = getControlValueAccessorBindings(componentInstance);
const writeValue$ = isRoot(options)
? options.input$.pipe(
// we need to start with a value here otherwise if a root form does not bind
// its input (and only uses an output, for example a filter) then
// `broadcastValueToParent$` would never start and we would never get updates
startWith(null))
: componentHooks.writeValue$;
const registerOnChange$ = isRoot(options)
? of(data => {
if (!data) {
return;
}
options.output$.next(data);
})
: componentHooks.registerOnChange$;
const setDisabledState$ = isRoot(options)
? options.disabled$ ?? of(false)
: componentHooks.setDisabledState$;
const transformedValue$ = writeValue$.pipe(map(value => {
if (isNullOrUndefined(value)) {
return defaultValues;
}
if (options.toFormGroup) {
return options.toFormGroup(value);
}
// if it's not a remap component, the ControlInterface === the FormInterface
return value;
}), shareReplay({ refCount: true, bufferSize: 1 }));
const broadcastDefaultValueToParent$ = !options.emitInitialValueOnInit
? EMPTY
: transformedValue$.pipe(take(1), switchMap(transformedValue => {
const transformedValueDelayed$ = of(transformedValue).pipe(delay(0), filter(() => formGroup.valid));
if (!isRoot(options)) {
return transformedValueDelayed$;
}
return of(transformedValue).pipe(filter(formValue => !options.outputFilterPredicate ? true : options.outputFilterPredicate(transformedValue, formValue)));
}), map(value => options.fromFormGroup
? options.fromFormGroup(value)
: // if it's not a remap component, the ControlInterface === the FormInterface
value));
const broadcastValueToParent$ = transformedValue$.pipe(switchMap(transformedValue => {
if (!isRoot(options)) {
return formGroup.valueChanges.pipe(delay(0));
}
else {
const formValues$ = options.manualSave$
? options.manualSave$.pipe(withLatestFrom(formGroup.valueChanges), map(([_, formValue]) => formValue))
: formGroup.valueChanges;
// it might be surprising to see formGroup validity being checked twice
// here, however this is intentional. The delay(0) allows any sub form
// components to populate values into the form, and it is possible for
// the form to be invalid after this process. In which case we suppress
// outputting an invalid value, and wait for the user to make the value
// become valid.
return formValues$.pipe(filter(() => formGroup.valid), delay(0), filter(formValue => {
if (formGroup.invalid) {
return false;
}
if (options.outputFilterPredicate) {
return options.outputFilterPredicate(transformedValue, formValue);
}
return !isEqual(transformedValue, formValue);
}), options.handleEmissionRate ?? identity);
}
}), map(value => options.fromFormGroup
? options.fromFormGroup(value)
: // if it's not a remap component, the ControlInterface === the FormInterface
value));
// components often need to know what the current value of the FormControl that it is representing is, usually for
// display purposes in the template. This value is the composition of the value written from the parent, and the
// transformed current value that was most recently written to the parent
const controlValue$ = merge(writeValue$, broadcastValueToParent$).pipe(shareReplay({ bufferSize: 1, refCount: true }));
const emitNullOnDestroy$ =
// emit null when destroyed by default
isNullOrUndefined(options.emitNullOnDestroy) || options.emitNullOnDestroy
? lifecyleHooks.onDestroy.pipe(mapTo(null))
: EMPTY;
const createFormArrayControl = optionsHaveInstructionsToCreateArrays(options) && options.createFormArrayControl
? options.createFormArrayControl
: (key, initialValue) => new UntypedFormControl(initialValue);
const sideEffects = {
broadcastValueToParent$: registerOnChange$.pipe(switchMap(onChange => broadcastValueToParent$.pipe(tap(value => onChange(value))))),
broadcastDefaultValueToParent$: registerOnChange$.pipe(switchMap(onChange => broadcastDefaultValueToParent$.pipe(tap(value => onChange(value))))),
applyUpstreamUpdateOnLocalForm$: transformedValue$.pipe(tap(value => {
handleFormArrays(formArrays, value, createFormArrayControl);
formGroup.reset(value, { emitEvent: false });
})),
supportChangeDetectionStrategyOnPush: concat(lifecyleHooks.afterViewInit.pipe(take(1)), merge(controlValue$, setDisabledState$).pipe(delay(0), tap(() => {
changeDetectorRef.markForCheck();
}))),
setDisabledState$: setDisabledState$.pipe(tap((shouldDisable) => {
shouldDisable ? formGroup.disable({ emitEvent: false }) : formGroup.enable({ emitEvent: false });
})),
updateValue$: updateValueAndValidity$.pipe(tap(() => {
formGroup.updateValueAndValidity({ emitEvent: false });
})),
bindTouched$: combineLatest([componentHooks.registerOnTouched$, options.touched$ ?? EMPTY]).pipe(delay(0), tap(([onTouched]) => onTouched())),
};
merge(...Object.values(sideEffects))
.pipe(takeUntil(lifecyleHooks.onDestroy))
.subscribe();
// following cannot be part of `forkJoin(sideEffects)`
// because it uses `takeUntilDestroyed` which destroys
// the subscription when the component is being destroyed
// and therefore prevents the emit of the null value if needed
registerOnChange$
.pipe(switchMap(onChange => emitNullOnDestroy$.pipe(tap(value => onChange(value)))), takeUntil(lifecyleHooks.onDestroy.pipe(delay(0))))
.subscribe();
return {
formGroup,
formControlNames,
get formGroupErrors() {
return getFormGroupErrors(formGroup);
},
createFormArrayControl,
controlValue$,
};
}
/*
* Public API Surface of sub-form
*/
/**
* Generated bundle index. Do not edit.
*/
export { FormType, MissingFormControlsError, NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES, createForm, createFormDataFromOptions, getControlValueAccessorBindings, getFormGroupErrors, handleFormArrays, isNullOrUndefined, patchClassInstance, subformComponentProviders, takeUntilDestroyed };
//# sourceMappingURL=ngx-sub-form.mjs.map