@studiohyperdrive/ngx-forms
Version:
An Angular package to help with complex forms and their validation.
1,177 lines (1,153 loc) • 65.3 kB
JavaScript
import clean from 'obj-clean';
import { isValid, format, parse } from 'date-fns';
import * as i0 from '@angular/core';
import { forwardRef, inject, Injector, ChangeDetectorRef, Directive, ViewChildren, Input, Output, HostListener, InjectionToken, Optional, Inject } from '@angular/core';
import { tap as tap$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
import * as i1 from '@angular/forms';
import { NG_VALUE_ACCESSOR, NG_VALIDATORS, NgControl } from '@angular/forms';
import { BehaviorSubject, Subject, filter, tap, takeUntil, combineLatest, startWith, of } from 'rxjs';
import { isEqual } from 'lodash';
const isEmptyInputValue = (value) => {
// we don't check for string here so it also works with arrays
return value == null || value.length === 0;
};
/**
* Removes an error from a form control
*
* @param control - Form control to remove the error from.
* @param error - Name of the error to remove from the control.
*/
const clearFormError = (control, error) => {
// Iben: Check if there are no errors existing on this control or if the the provided error does not exist, and early exit if needed
const errors = new Set(Object.keys(control.errors || {}));
if (errors.size === 0 || !errors.has(error)) {
return;
}
// Iben: In case the provided error is the only error on the control, clear all errors and early exit
if (errors.has(error) && errors.size === 1) {
control.setErrors(null);
return;
}
// Iben: In case there are more errors, remove only the provided error
control.setErrors(clean({
...control.errors,
[error]: undefined,
}));
};
/**
* Adds an error to a form control
*
* @param control - Form control to attach the error to.
* @param error - Name of the error to attach to the control.
* @param value - Value of the error being attached to the control
*/
const setFormError = (control, error, value = true) => {
// Iben: Early exit in case the control already has the error
if (control.hasError(error)) {
return;
}
// Iben: Add the provided error
control.setErrors({
...control.errors,
[error]: value,
});
};
const EMPTY_SET$1 = new Set([undefined, null, '']);
/**
* FormGroup validator which checks if either all values or no values are filled in
*
* @param controls - An array of controls.
* @param dependedControlKey - A control within the group which the other controls depend on.
* @param matchFunction - Optional function the dependedControl should check
*/
const allOrNothingRequiredValidator = (form) => {
const keys = Object.keys(form.value);
// Iben: If the group is completely empty we clear all required errors
if (Object.keys(clean(form.value, { preserveArrays: false })).length === 0) {
for (const key of keys) {
clearFormError(form.get(key), 'required');
}
return null;
}
// Iben: Collect all control keys that are missing values
const requiredKeys = new Set();
// Iben: Loop over all keys and check each control on whether it is empty or not
keys.forEach((key) => {
const control = form.get(key);
// Iben: Check if the control is empty
const isEmpty = typeof control.value === 'object' && control.value !== null
? Object.keys(clean(control.value)).length === 0
: EMPTY_SET$1.has(control.value);
// Iben: Add/remove the errors when needed
if (isEmpty) {
setFormError(control, 'required');
requiredKeys.add(key);
}
else {
clearFormError(control, 'required');
requiredKeys.delete(key);
}
});
// Iben: Return either null or the list of controls that are missing values based on the empty state
return requiredKeys.size === 0 ? null : { allOrNothingRequiredError: Array.from(requiredKeys) };
};
/**
* FormGroup validator which checks if either at least one value is filled in
*
* @param options - An optional object with configuration options, see below params for more info
*/
const atLeastOneRequiredValidator = (options) => {
return (group) => {
// Iben: Get the optional configuration items
let conditionalFunction;
let keys;
if (options) {
conditionalFunction = options.conditionalFunction;
keys = options.controls;
}
// Iben: Setup the needed variables to handle the validator
const cleanedFormValue = clean(group.value);
const cleanedKeys = new Set(Object.keys(cleanedFormValue));
const controls = Object.values(group.controls);
const empty = cleanedKeys.size === 0;
// Iben: If nothing is filled in, we return an error
if ((empty && !conditionalFunction) ||
(empty && conditionalFunction && conditionalFunction(group.value))) {
for (const control of controls) {
setFormError(control, 'required');
}
return { atLeastOneRequiredError: true };
}
// Iben: Check if we need to check on a specific key
if (keys) {
const hasOneKey = keys.reduce((hasOne, key) => hasOne || cleanedKeys.has(key), false);
// Iben: Only return an error when there is no key matched at all
// and in case of a conditionalFunction if the conditionalFunction is matched as well
if ((!hasOneKey && !conditionalFunction) ||
(!hasOneKey && conditionalFunction && conditionalFunction(group.value))) {
for (const key of keys) {
setFormError(group.get(key), 'required');
}
return { atLeastOneRequiredError: true };
}
}
// Iben: In case there are no errors, clean the required errors and return null
for (const control of controls) {
clearFormError(control, 'required');
}
return null;
};
};
const EMPTY_SET = new Set([undefined, null, '']);
/**
* FormGroup validator which checks if an array of controls in the control are filled in if the depended control is filled in
*
* @param controls - An array of controls.
* @param dependedControlKey - A control within the group which the other controls depend on.
* @param matchFunction - Optional function the dependedControl should check
*/
const dependedRequiredValidator = (controls, dependedControlKey, matchFunction) => {
return (form) => {
// Iben: Make a set so we know which controls are not filled in
const keysWithErrors = new Set();
const dependedControl = form.get(dependedControlKey);
// Iben: If the control is not filled in or the value doesn't match, we do an early exit and remove all potential required errors
if (!dependedControl ||
!(matchFunction
? matchFunction(dependedControl.value)
: !EMPTY_SET.has(dependedControl.value))) {
for (const key of controls) {
const control = form.get(key);
// Continue if control does not exist
if (!control) {
continue;
}
clearFormError(control, 'required');
}
return null;
}
// Iben: Set an overall error so we can see if all controls are filled in or not
let hasError = false;
for (const key of controls) {
const control = form.get(key);
// Iben: Continue if control does not exist
if (!control) {
continue;
}
hasError = hasError || EMPTY_SET.has(control.value);
// Iben: If the control is not filled in we set a required error, if not, we remove it
if (!EMPTY_SET.has(control.value)) {
clearFormError(control, 'required');
keysWithErrors.delete(key);
}
else {
setFormError(control, 'required');
keysWithErrors.add(key);
}
}
const errors = Array.from(keysWithErrors);
return hasError ? { hasDependedRequiredError: errors } : null;
};
};
/**
* Validates whether the inputted value has exceeded the maximum amount of decimals after the comma
*
* @param max - The maximum number of decimals after the comma
*/
const decimalsAfterCommaValidator = (max) => {
return (control) => {
// Iben: In case no control was provided, or the control value was empty, we early exit
if (!control || (!control.value && control.value !== 0)) {
return null;
}
// Iben: We check if the input value matches the amount of decimals after the comma, if not, we return an error
return new RegExp(`^\\d+(.\\d{1,${max}})?$`).test(`${control.value}`)
? null
: { invalidDecimalsAfterComma: true };
};
};
/**
* A FormGroup validator to check whether a start and end date are chronologically correct
*
* @param startControlKey - The key of the control containing the start date value
* @param endControlKey - The key of the control containing the end date value
* @param format - Optional format of the dates provided by the controls, by default yyyy-MM-dd
*/
const chronologicalDatesValidator = (startControlKey, endControlKey, dateFormat = 'yyyy-MM-dd') => {
return (form) => {
// Iben: Get the date values
const value = form.getRawValue();
const startValue = value[startControlKey];
const endValue = value[endControlKey];
// Iben: Clear the form error on the endControl
clearFormError(form.get(endControlKey), 'incorrectChronologicalDate');
// Iben: If either date value is not filled in, we early exit to handle this in a potential required validator
if (!startValue || !endValue) {
return null;
}
// Iben: If the dates as is are not valid, early exit
if (!isValid(new Date(startValue)) || !isValid(new Date(endValue))) {
return null;
}
// Iben: Create dates so we can compare them
const startDate = format(new Date(startValue), dateFormat);
const endDate = format(new Date(endValue), dateFormat);
// Iben: If either date is invalid based on the format, we early exit to handle this in a date validator
if (!isValid(new Date(startDate)) || !isValid(new Date(endDate))) {
return null;
}
// Iben: If the endDate falls before the startDate, we return an error
if (endDate < startDate) {
setFormError(form.get(endControlKey), 'incorrectChronologicalDate');
return { incorrectChronologicalDates: true };
}
return null;
};
};
const extendedEmailValidator = (control) => {
if (isEmptyInputValue(control.value)) {
return null; // don't validate empty values to allow optional controls
}
// Validates more strictly than the default email validator. Requires a period in the tld part.
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]+$/gi.test(control.value)
? null
: { extendedEmail: true };
};
/**
* hasNoFutureDateValidator
*
* Validator function to ensure that the selected date is not in the future.
* If the date is in the future, it returns an error.
* @returns ValidationErrors if the date is in the future, otherwise null.
*
*/
const hasNoFutureDateValidator = () => {
return (control) => {
// Early exit in case the control or the value does not exist
if (!control.value) {
return null;
}
// Create date objects based on the provided date and current date
const inputDate = new Date(control.value);
const currentDate = new Date();
// In case the date itself is invalid, we early exit to let a potential date validator handle the error
if (!isValid(inputDate)) {
return null;
}
return inputDate <= currentDate ? null : { isFutureDate: { valid: false } };
};
};
/**
* Form control validator which validates if a date is between a provided range (edges not included)
*
* @param minDate - Minimum valid date
* @param maxDate - Maximum valid date
* @param format - Optional format used for all 3 dates, by default yyyy-MM-dd
*/
const dateRangeValidator = (min, max, format = 'yyyy-MM-dd') => {
return (control) => {
// Iben: Early exit in case the control or the value does not exist
if (!control?.value) {
return null;
}
// Iben : Create date objects based on the provided dates
const date = parse(control.value, format, new Date());
const maxDate = parse(max, format, new Date());
const minDate = parse(min, format, new Date());
// Iben: In case either of the boundary dates is invalid, we mark the input as invalid as we cannot confirm it's in the right range
if (!isValid(maxDate) || !isValid(minDate)) {
return {
invalidRange: !isValid(maxDate) ? 'invalidMaxDate' : 'invalidMinDate',
};
}
// Iben: In case the date itself is invalid, we early exit to let a potential date validator handle the error
if (!isValid(date)) {
return null;
}
// Iben: We check if the date is in between the boundaries and return an error if need be
if (!(minDate <= date) || !(date <= maxDate)) {
return {
invalidRange: date > maxDate ? 'dateAfterMaxDate' : 'dateBeforeMinDate',
};
}
return null;
};
};
/**
* WordCountValidator
*
* The WordCountValidator validator will check the amount of words provided in a control.
*
* @param .min
* @param .max
* @returns ValidatorFn
*/
const WordCountValidator = ({ min, max }) => {
return (control) => {
if (typeof control?.value !== 'string' ||
(typeof min !== 'number' && typeof max !== 'number')) {
return null;
}
const wordCount = control.value.trim().split(' ').length;
if (typeof min === 'number' && wordCount <= min) {
return { minWordCountNotReached: { valid: false } };
}
if (typeof max === 'number' && wordCount > max) {
return { maxWordCountReached: { valid: false } };
}
return null;
};
};
/**
* CompareValidator
*
* The CompareValidator will return a validator that compares the values of two FormControls
* within a FormGroup based on a given comparator function.
*
* Note: This validator will only set an error on the group it is set to
* unless the `setErrorOnKey` argument is given.
*
* @param keys {string[]}
* @param comparatorFn {(...args: ValueType[]) => boolean}
* @param setErrorOnKey {string}
* @returns {(group: FormGroup<{ [key: string]: FormControl<ValueType>; }>) => ValidationErrors}
*/
const CompareValidator = (keys, comparatorFn, setErrorOnKey) => {
return (group) => {
// Denis: map the values to an array:
const values = keys.map((key) => group?.get(key).getRawValue());
const setErrorOnKeyControl = group?.get(setErrorOnKey);
// Denis: check if any of the keys contains an undefined or null value:
if (values.some((value) => typeof value === 'undefined' || value === null)) {
setErrorOnKeyControl && clearFormError(group.get(setErrorOnKey), 'compareError');
return null;
}
if (comparatorFn(...values)) {
setErrorOnKeyControl && setFormError(group.get(setErrorOnKey), 'compareError');
return {
compareError: true,
};
}
setErrorOnKeyControl && clearFormError(group.get(setErrorOnKey), 'compareError');
return null;
};
};
/**
* Exported Class
*/
class NgxValidators {
/**
* A stricter validator for e-mail validation
*
* @param control - A form control
*/
static extendedEmail(control) {
return extendedEmailValidator(control);
}
/**
* A validator to check if all or none of the values of a form group are filled in.
* Particularly useful in situations where a form group field within itself is optional,
* but all fields are required in case it does get filled in
*
* Returns an `allOrNothingRequiredError` error on the provided FormGroup and a `required` error on the individual controls
*
* @param control - A form group control
*/
static allOrNothingRequired(control) {
return allOrNothingRequiredValidator(control);
}
/**
* A validator to check if at least one of the provided controls of the form group are filled in
*
* Returns an `atLeastOneRequiredError` error on the provided FormGroup and a `required` error on the individual controls
*
* @param options - An optional object with configuration options, see below params for more info
* @param controlNames - Optional list of controls, if not provided the validator is applied to all controls of the group
* @param conditionalFunction - Optional function the form value needs to return true to for the required to be se
*/
static atLeastOneRequired(options) {
return atLeastOneRequiredValidator(options);
}
/**
* The compareValidator will return a validator that compares the values of two FormControls
* within a FormGroup based on a given comparator function.
*
* Returns a `compareError` on the provided FormGroup and on the individual controls if the `setErrorKey` argument is provided.
*
* @param keys {string[]}
* @param comparatorFn {(...args: ValueType[]) => boolean}
* @param setErrorOnKey {string}
* @returns {(group: FormGroup<{ [key: string]: FormControl<ValueType>; }>) => ValidationErrors}
*
*/
static compareValidator(keys, comparatorFn, setErrorOnKey) {
return CompareValidator(keys, comparatorFn, setErrorOnKey);
}
/**
* FormGroup validator which checks if an array of controls in the control are filled in if the depended control is filled in
*
* Returns a `hasDependedRequiredError` error on the provided FormGroup and a `required` error on the individual controls
*
* @param controls - An array of controls.
* @param dependedControlKey - A control within the group which the other controls depend on.
* @param matchFunction - Optional function the dependedControl should check
*/
static dependedRequired(controls, dependedControlKey, matchFunction) {
return dependedRequiredValidator(controls, dependedControlKey, matchFunction);
}
/**
* Validates whether the inputted value has exceeded the maximum amount of decimals after the comma
*
* Returns an `invalidDecimalsAfterComma` error on the provided control
*
* @param max - The maximum number of decimals after the comma
*/
static decimalsAfterComma(max) {
return decimalsAfterCommaValidator(max);
}
/**
* A FormGroup validator to check whether a start and end date are chronologically correct
*
* Returns an `incorrectChronologicalDates` error on the provided FormGroup and a `incorrectChronologicalDate` on the endControl
*
* @param startControlKey - The key of the control containing the start date value
* @param endControlKey - The key of the control containing the end date value
* @param format - Optional format of the dates provided by the controls, by default yyyy-MM-dd
*/
static chronologicalDates(startControlKey, endControlKey, format = 'yyyy-MM-dd') {
return chronologicalDatesValidator(startControlKey, endControlKey, format);
}
/**
* Form control validator which validates if a date is between a provided range
*
* Returns an `invalidRange` error
*
* @param minDate - Minimum valid date
* @param maxDate - Maximum valid date
* @param format - Optional format used for all 3 dates, by default yyyy-MM-dd
*/
static dateRangeValidator(min, max, format = 'yyyy-MM-dd') {
return dateRangeValidator(min, max, format);
}
/**
* Form control validator which validates if a date is not in the future.
*
* Returns an `isFutureDate` error
*/
static { this.hasNoFutureDateValidator = () => {
return hasNoFutureDateValidator();
}; }
/**
* Form control validator which validates if a provided string does not contain more or less words than a provided min and/or max.
*
* Returns either a `minWordCountNotReached` or a `maxWordCountReached`
*/
static { this.wordCountValidator = ({ min, max }) => {
return WordCountValidator({ min, max });
}; }
}
/**
* In order to select all accessors in a FormContainer, we need this base class to pass to our ViewChildren.
*
* IMPORTANT: This will never be used as an actual functional component
*/
class BaseFormAccessor {
}
/**
* Allows for a deep markAsDirty of all controls. Can be used for a FormGroup or a FormArray
*
* @param controls - The controls we wish to update the value and validity of
* @param onlySelf - Whether or not we want it to be only the control itself and not the direct ancestors. Default this is true
*/
const markAllAsDirty = (controls, options = {}) => {
// Iben: We loop over all controls
(Array.isArray(controls) ? controls : Object.values(controls)).forEach((control) => {
// Iben: If there are no child controls, we update the value and validity of the control
if (!control['controls']) {
control.markAsDirty(options);
return;
}
// Iben: If there are child controls, we recursively update the value and validity
markAllAsDirty(control['controls'], options);
});
};
/**
* Adds a deep update value and validity to the existing update value and validity
*
* @param form - The provided abstract control
* @param options - The options we wish to call along with the update value and validity function
*/
const updateAllValueAndValidity = (form, options = {}) => {
// Iben: Call the original updateValueAndValidity
form.updateValueAndValidity(options);
// Iben: If we don't have the inner form yet we just do the default update value
if (!form || !form['controls']) {
return;
}
// Iben: We update the value and validity recursively for each child control
deepUpdateValueAndValidity(form['controls'], { ...options, onlySelf: true });
};
/**
* Allows for a deep updateValueAndValidity of all controls. Can be used for a FormGroup or a FormArray
*
* @param controls - The controls we wish to update the value and validity of
* @param onlySelf - Whether or not we want it to be only the control itself and not the direct ancestors. Default this is true
*/
const deepUpdateValueAndValidity = (controls, options = {}) => {
// Iben: We loop over all controls
(Array.isArray(controls) ? controls : Object.values(controls)).forEach((control) => {
// Iben: If there are no child controls, we update the value and validity of the control
if (!control['controls']) {
control.updateValueAndValidity(options);
return;
}
// Iben: If there are child controls, we recursively update the value and validity
deepUpdateValueAndValidity(control['controls'], options);
});
};
/**
* Disable a FormControl/FormArray
*
* @param keys - The keys of the fields we wish to disable
* @param emitEvent - Whether or not we wish to emit the event
*/
const handleDisableFormControlOfFormArray = (form, keys, emitEvent) => {
// Iben: Early exit in case the state already matches so we don't do unnecessary emits
if ((keys.has('formAccessorSelf') && form.disabled) ||
(!keys.has('formAccessorSelf') && form.enabled)) {
return;
}
// Iben: Disable/enable the control based on the key
keys.has('formAccessorSelf') ? form.disable({ emitEvent }) : form.enable({ emitEvent });
};
/**
* Disable the controls of a FormGroup
*
* @param keys - The keys of the fields we wish to disable
* @param emitEvent - Whether or not we wish to emit the event
*/
const handleDisableFormGroup = (form, keys, emitEvent) => {
// Iben: Loop over all controls and enable them so that they are re-enabled in case the set of keys changes
enableControls(form, emitEvent);
// Iben: Disable the keys
Array.from(keys).forEach((key) => {
const control = form.get(key);
if (!control) {
console.warn(`FormAccessor: The key "${key}" was provided in the disableFields array but was not found in the provided form.`);
return;
}
// Iben: Prevent emit event if the control is already disabled
if (!control.disabled) {
control.disable({ emitEvent });
}
});
};
/**
* Recursively enables all fields of a control
*
* @param control - An AbstractControl which we want to enable and enable all children off
* @param emitEvent - Whether or not we wish to emit an event
*/
const enableControls = (control, emitEvent = false) => {
//Iben: If no control was found, early exit
if (!control) {
return;
}
// Iben: Enable the control itself if it is not enabled yet
if (!control.enabled) {
control.enable({ emitEvent });
}
// Iben: If there are no controls, early exit
if (!control['controls']) {
return;
}
// Iben: Recursively enable each control
(Array.isArray(control['controls'])
? control['controls']
: Object.values(control['controls'])).forEach((child) => {
enableControls(child, emitEvent);
});
};
/**
* Disables and enables a form's control based on a set of provided keys
*
* @param form - The form we wish to disable the controls for
* @param controlKeys - A set of keys of the controls
* @param emitEvent - Whether or not we wish to emit the event
*/
const handleFormAccessorControlDisabling = (form, controlKeys, emitEvent) => {
// Iben: Depending on whether we're dealing with a FormArray/FormControl or a FormGroup, we have different and handle the disable/enable state
if (!form['controls'] || Array.isArray(form['controls'])) {
handleDisableFormControlOfFormArray(form, controlKeys, emitEvent);
}
else {
handleDisableFormGroup(form, controlKeys, emitEvent);
}
};
/**
* Marks a form and all the form-accessors this form is based on as dirty
*
* @param form - The form we wish to mark as dirty
* @param accessors - An array of all the accessors we wish to mark as dirty
* @param options - Form state options we wish to provide
*/
const handleFormAccessorMarkAsDirty = (form, accessors, options = {}) => {
// Iben: If the control has child controls, recursively mark them as dirty
if (form['controls']) {
markAllAsDirty(form['controls'], options);
}
else {
// Iben : Mark the form as dirty
form.markAsDirty(options);
}
// Iben: Loop over each form accessor and call the mark as dirty function, so all subsequent accessors are also marked as dirty
accessors.forEach((accessor) => accessor.markAsDirty(options));
};
/**
* Marks a form and all the form-accessors this form is based on as touched
*
* @param form - The form we wish to mark as touched
* @param accessors - An array of all the accessors we wish to mark as touched
* @param options - Form state options we wish to provide
*/
const handleFormAccessorMarkAsTouched = (form, accessors, options = {}) => {
// Iben: Mark all the controls and the children as touched
form.markAllAsTouched();
// Iben: Loop over each form accessor and call the mark as touched function, so all subsequent accessors are also marked as touched
accessors.forEach((accessor) => accessor.markAsTouched(options));
};
/**
* Marks a form and all the form-accessors this form is based on as pristine
*
* @param form - The form we wish to mark as pristine
* @param accessors - An array of all the accessors we wish to mark as pristine
* @param options - Form state options we wish to provide
*/
const handleFormAccessorMarkAsPristine = (form, accessors, options = {}) => {
// Iben: Mark all the controls and the children as touched
form.markAsPristine();
// Iben: Loop over each form accessor and call the mark as touched function, so all subsequent accessors are also marked as touched
accessors.forEach((accessor) => accessor.markAsPristine(options));
};
/**
* Updates a form and all the form-accessors this form i
*
* @param form - The form we wish to update the value and validity of
* @param accessors - An array of all the accessors we wish to update the value and validity of
* @param options - Form state options we wish to provide
*/
const handleFormAccessorUpdateValueAndValidity = (form, accessors, options = {}) => {
// Iben: Update the value and validity of the form
updateAllValueAndValidity(form, options);
// Iben: Loop over each form accessor and call the updateValueAndValidity function, so all subsequent accessors are also updated
accessors.forEach((accessor) => accessor.updateAllValueAndValidity(options));
};
/**
* Recursively checks if a form and its possible children have an error
*
* @param control - The provided abstract control
*/
const hasErrors = (control) => {
// Iben: If the form has no children we just return the state of the current form
if (!control['controls']) {
return control.invalid;
}
// Iben: If the form has children, we check if some of the child controls have errors
const controls = control['controls'];
return (Array.isArray(controls) ? controls : Object.values(controls)).some((control) => hasErrors(control));
};
/**
* Listen to the touched event of a control
*
* @param control - An AbstractControl
*/
const touchedEventListener = (control) => {
// Iben: Grab the current markAsTouched and UnTouched methods
const markAsTouched = control.markAsTouched;
const markAsUnTouched = control.markAsUntouched;
// Iben: Set a subject with the current touched state
const touchedSubject = new BehaviorSubject(control.touched);
// Iben: Overwrite the existing functions and emit the touched state
control.markAsTouched = (options) => {
touchedSubject.next(true);
markAsTouched.bind(control)(options);
};
control.markAsUntouched = (options) => {
touchedSubject.next(false);
markAsUnTouched.bind(control)(options);
};
// Iben: Return the touched state
return touchedSubject.asObservable();
};
/**
* Generates the necessary providers for a (Data)FormAccessor.
*
* @param component - The component class of the (Data)FormAccessor
*/
const createAccessorProviders = (component) => {
return [
// Iben: Generate a provider for the control handling
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => component),
multi: true,
},
// Iben: Generate a provider for the validation handling
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => component),
multi: true,
},
// Iben: Generate a provider for the FormContainer handling
{
provide: BaseFormAccessor,
useExisting: forwardRef(() => component),
},
];
};
class NgxFormsControlValueAccessor {
/**
* Keys of the fields we wish to disable.
* By default this will emit a valueChanges, this can be overwritten by the emitValueWhenDisableFieldsUsingInput in the Accessor
*
* @memberof FormAccessor
*/
set disableFields(keys) {
// Iben: Early exit in case the keys are not provided
if (!keys) {
return;
}
// Iben: Setup a subject to track whether we're still disabling the fields
const disabling = new Subject();
// Iben: Add the keys to a set for more performant lookup and convert those to a string to not have Typescript issues later down the line
const controlKeys = new Set(keys);
// Iben: Check if we need to dispatch the disable or enable event
const emitEvent = this.emitValueWhenDisableFieldsUsingInput
? this.emitValueWhenDisableFieldsUsingInput(keys)
: true;
// Iben: Listen to the initialized state of the form
this.initialized$
.pipe(filter(Boolean), tap(() => {
// TODO: Iben: Remove this setTimeout once we're in a Signal based component
setTimeout(() => {
// Iben: Handle the disabling of the fields
handleFormAccessorControlDisabling(this.form, controlKeys, emitEvent);
});
// Iben: Set the disabling subject so that we can complete this subscription
disabling.next(undefined);
disabling.complete();
}), takeUntil(disabling))
.subscribe();
}
constructor() {
/**
* The Injector needed in the constructor
*/
this.injector = inject(Injector);
/**
* The ChangeDetector reference
*/
this.cdRef = inject(ChangeDetectorRef);
/**
* A subject to hold the parent control
*/
this.parentControlSubject$ = new Subject();
/**
* A reference to the control tied to this control value accessor
*/
this.parentControl$ = this.parentControlSubject$.pipe(filter(Boolean));
/**
* Whether the first setDisable has run
*/
this.initialSetDisableHasRun = false;
/**
* On destroy flow handler
*/
this.destroy$ = new Subject();
/**
* Subject to check whether the form is initialized
*/
this.initializedSubject$ = new BehaviorSubject(false);
/**
* Whether we want to skip the first setDisable (https://github.com/angular/angular/pull/47576).
* By default, this is true
*/
this.skipInitialSetDisable = true;
/**
* Stream to know whether the form has been initialized
*/
this.initialized$ = this.initializedSubject$.asObservable();
/**
* Sets up the ControlValueAccessor connectors
*/
this.onTouch = () => { }; // tslint:disable-line:no-empty
this.onChange = (_) => { }; // tslint:disable-line:no-empty
// Iben: Use setTimeOut to avoid the circular dependency issue
setTimeout(() => {
try {
const parentControl = this.injector.get(NgControl);
// Iben: If for some reason we can't find the control or the ngControl, early exit and throw an error
if (!parentControl?.control) {
console.error('NgxForms: No control was found after initializing. Check if a control was assigned to the FormAccessor.');
return;
}
this.parentControlSubject$.next(parentControl.control);
// Iben: Grab the control from the parent container
const control = parentControl.control;
// Iben: Setup the markAsTouched flow
// Iben: Keep a reference to the original `markAsTouched` handler.
const markAsTouched = control.markAsTouched.bind(control);
// Iben: Override the `markAsTouched` handler with our own.
control.markAsTouched = (options) => {
// Iben: If the control is already marked as touched, we early exit
if (control.touched) {
return;
}
// Iben: Invoke the original `markAsTouchedHandler`.
markAsTouched(options);
// Iben: If the onlySelf flag is set to true, we early exit
if (options?.onlySelf) {
return;
}
// Iben: Invoke the custom `markAsTouchedHandler`.
this.markAsTouched(options);
};
// Iben: Setup the markAsDirty flow
// Iben: Keep a reference to the original `markAsDirty` handler.
const markAsDirty = control.markAsDirty.bind(control);
// Iben: Override the `markAsDirty` handler with our own.
control.markAsDirty = (options) => {
// Iben: If the control is already marked as dirty, we early exit
if (control.dirty) {
return;
}
// Iben: Invoke the original `markAsDirtyHandler`.
markAsDirty(options);
// Iben: If the onlySelf flag is set to true, we early exit
if (options?.onlySelf) {
return;
}
// Iben: Invoke the custom `markAsDirtyHandler`.
this.markAsDirty(options);
};
// Iben: Setup the markAsPristine flow
// Iben: Keep a reference to the original `markAsPristine` handler.
const markAsPristine = control.markAsPristine.bind(control);
// Iben: Override the `markAsPristine` handler with our own.
control.markAsPristine = (options) => {
// Iben: If the control is already marked as pristine, we early exit
if (control.pristine) {
return;
}
// Iben: Invoke the original `markAsPristineHandler`.
markAsPristine(options);
// Iben: If the onlySelf flag is set to true, we early exit
if (options?.onlySelf) {
return;
}
// Iben: Invoke the custom `markAsPristineHandler`.
this.markAsPristine(options);
};
}
catch (error) {
console.warn('NgxForms: No parent control was found while trying to set up the form accessor.');
}
});
}
registerOnChange(fn) {
this.onChange = fn;
}
registerOnTouched(fn) {
this.onTouch = fn;
}
/**
* Writes value to the inner form
*
* @param value - Value to patch in the inner form
*/
writeValue(value) {
// Iben: Early exit in case the form was not found
if (!this.form) {
console.error('NgxForms: No form was found when trying to write a value. This error can occur when overwriting the ngOnInit without invoking super.OnInit().');
return;
}
// Iben: Reset the current form without emitEvent to not trigger the valueChanges
this.form.reset(undefined, { emitEvent: false });
// Iben: Patch the current form with the new value without emitEvent to not trigger the valueChanges
if (value !== undefined && value !== null) {
this.form.patchValue(this.onWriteValueMapper ? this.onWriteValueMapper(value) : value, {
emitEvent: false,
});
}
// Iben: Validate the current value
this.validate();
// Iben: Detect changes so the changes are visible in the dom
this.cdRef.detectChanges();
}
/**
* Mark all controls of the form as touched
*/
markAsTouched(options = {}) {
handleFormAccessorMarkAsTouched(this.form, this.accessors?.toArray() || [], options);
// Iben: Detect changes so the changes are visible in the dom
this.cdRef.detectChanges();
}
/**
* Mark all controls of the form as dirty
*/
markAsDirty(options = {}) {
handleFormAccessorMarkAsDirty(this.form, this.accessors?.toArray() || [], options);
// Iben: Detect changes so the changes are visible in the dom
this.cdRef.detectChanges();
}
/**
* Mark all controls of the form as pristine
*/
markAsPristine(options = {}) {
handleFormAccessorMarkAsPristine(this.form, this.accessors?.toArray() || [], options);
// Iben: Detect changes so the changes are visible in the dom
this.cdRef.detectChanges();
}
/**
* Update the value and validity of the provided form
*/
updateAllValueAndValidity(options) {
handleFormAccessorUpdateValueAndValidity(this.form, this.accessors?.toArray() || [], options);
// Iben: Detect changes so the changes are visible in the dom
this.cdRef.detectChanges();
}
/**
* Validates the inner form
*/
validate() {
// Iben: If the form itself is invalid, we return the invalidForm: true right away
if (this.form.invalid) {
return { invalidForm: true };
}
// Iben: In case the form is invalid, we check if the child controls are possibly invalid
return hasErrors(this.form) ? { invalidForm: true } : null;
}
/**
* Disables/enables the inner form based on the passed value
*
* @param isDisabled - Whether or not the form should be disabled
*/
setDisabledState(isDisabled) {
// Iben: Skip the initial setDisabled, as this messes up our form approach.
// https://github.com/angular/angular/pull/47576
if (this.skipInitialSetDisable && !this.initialSetDisableHasRun) {
this.initialSetDisableHasRun = true;
return;
}
if (isDisabled) {
this.form.disable({ emitEvent: false });
}
else {
this.form.enable({ emitEvent: false });
}
// Iben: Detect changes so the changes are visible in the dom
this.cdRef.detectChanges();
}
ngOnDestroy() {
this.destroy$.next(undefined);
this.destroy$.complete();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxFormsControlValueAccessor, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.3", type: NgxFormsControlValueAccessor, isStandalone: true, inputs: { disableFields: "disableFields", skipInitialSetDisable: "skipInitialSetDisable" }, outputs: { initialized$: "initialized$" }, viewQueries: [{ propertyName: "accessors", predicate: BaseFormAccessor, descendants: true }], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxFormsControlValueAccessor, decorators: [{
type: Directive
}], ctorParameters: () => [], propDecorators: { accessors: [{
type: ViewChildren,
args: [BaseFormAccessor]
}], disableFields: [{
type: Input
}], skipInitialSetDisable: [{
type: Input
}], initialized$: [{
type: Output
}] } });
class FormAccessor extends NgxFormsControlValueAccessor {
ngOnInit() {
// Iben: Set the inner form
this.form = this.initForm();
// Iben: Early exit in case the form was not found
if (!this.form) {
console.error('NgxForms: No form was found after initializing. Check if the initForm method returns a form.');
return;
}
// Iben: Warn the initialized$ observable that the form has been set up
this.initializedSubject$.next(true);
// Iben: Listen to the changes and warn the parent form
this.form.valueChanges
.pipe(tap$1((value) => {
// In case there's a mapper we map the value, else we send the form value
this.onChange(this.onChangeMapper ? this.onChangeMapper(value) : value);
}), takeUntil$1(this.destroy$))
.subscribe();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: FormAccessor, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.3", type: FormAccessor, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: FormAccessor, decorators: [{
type: Directive
}] });
class DataFormAccessor extends NgxFormsControlValueAccessor {
set data(data) {
// Iben: If we already have current data and the current data matches the new data, we don't make a new form
if (this.currentData && isEqual(this.currentData, data)) {
this.currentData = data;
return;
}
this.initializedSubject$.next(false);
this.currentData = data;
// Iben: Emit to the destroy so the previous subscription is cancelled
this.destroy$.next(undefined);
// Set the inner form
this.form = this.initForm(data);
// Iben: Early exit in case the form was not found
if (!this.form) {
console.error('NgxForms: No form was found after initializing. Check if the initForm method returns a form.');
return;
}
// Denis: set the initialized property
this.setInitializedWithData(data);
// Iben: Check if the form is valid depending on the provided value
this.validate();
this.cdRef.detectChanges();
// Iben: Subscribe to the value changes
this.form.valueChanges
.pipe(tap$1((value) => {
// In case there's a mapper we map the value, else we send the form value
this.onChange(this.onChangeMapper ? this.onChangeMapper(value) : value);
}), takeUntil$1(this.destroy$))
.subscribe();
}
/**
* setInitialized
*
* This method sets the initialized property to true when the form is initialized.
* This functionality has been moved to a separate method to enable
* overwriting this method to fit certain use-cases.
*
* @param {ConstructionDateType} data
* @returns void
* @private
*/
setInitializedWithData(data) {
this.initializedSubject$.next(Array.isArray(data) ? data && data.length > 0 : !!data);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: DataFormAccessor, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.3", type: DataFormAccessor, isStandalone: true, inputs: { data: "data" }, usesInheritance: true, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: DataFormAccessor, decorators: [{
type: Directive
}], propDecorators: { data: [{
type: Input,
args: [{ required: true }]
}] } });
class FormAccessorContainer {
constructor() {
/**
* Destroyed state of the component
*/
this.destroyed$ = new Subject();
}
/**
* @deprecated This method should no longer be used, use the markAsDirty on the form itself instead
*
* Marks the form and all the inputs of every subsequent form-accessors as dirty
*
* @param form - The form used in the component
* @param options - Options passed to the form state changer
*/
markAllAsDirty(form, options = {}) {
this.handleAccessorsAction(() => {
handleFormAccessorMarkAsDirty(form, this.accessors?.toArray() || [], options);
});
}
/**
* @deprecated This method should no longer be used, use the markAsTouched on the form itself instead
*
* Marks the form and all the inputs of every subsequent form-accessors as touched
*
* @param form - The form used in the component
* @param options - Options passed to the form state changer
*/
markAllAsTouched(form, options = {}) {
this.handleAccessorsAction(() => {
handleFormAccessorMarkAsTouched(form, this.accessors?.toArray() || [], options);
});
}
/**
* Updates the value and validity of the form and all the inputs of every subsequent form-accessors
*
* @param form - The provided forms
* @param options - Options passed to the updateValueAndValidity
*/
updateAllValueAndValidity(form, options = {}) {
this.handleAccessorsAction(() => {
handleFormAccessorUpdateValueAndValidity(form, this.accessors?.toArray() || [], options);
});
}
/**
* Handle the destroy state of the component
*/
ngOnDestroy() {
this.destroyed$.next(undefined);
this.destroyed$.complete();
}
/**
* Handle the accessors action of the FormContainer and throw a warning if no accessors are provided
*
* @param action - The provided action
*/
handleAccessorsAction(action) {
// Iben: Throw a warn in case there are no accessors found
if (!this.accessors || this.accessors?.toArray().length === 0) {
console.warn('NgxForms: No (Data)FormAccessors were found in this component. Check if each (Data)FormAccessor also provides the BaseFormAccessor in its providers array. If this is intentional, this warning can be ignored.');
}
// Iben: Handle the provided action
action();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: FormAccessorContaine