UNPKG

ngx-sub-form

Version:

![ngx-sub-form logo](https://user-images.githubusercontent.com/4950209/53812385-45f48900-3f53-11e9-8687-b57cd335f26e.png)

327 lines (319 loc) 14.6 kB
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