import { escapeHtml, FieldProperties, HTML5CheckTypes, HTML5InputTypes, parseToNumber } from '@decaf-ts/ui-decorators';
import { FieldUpdateMode, FormParentGroup, KeyValue } from './types';
import { IComponentConfig, IComponentInput } from './interfaces';
import { AbstractControl, FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { isValidDate, ModelKeys, parseDate, Primitives, Validation } from '@decaf-ts/decorator-validation';
import { ValidatorFactory } from './ValidatorFactory';
import { cleanSpaces } from '../helpers';
import { OperationKeys } from '@decaf-ts/db-decorators';
import { AngularEngineKeys, BaseComponentProps } from '../engine/constants';
/**
* @description Service for managing Angular forms and form controls.
* @summary The NgxFormService provides utility methods for creating, managing, and validating Angular forms and form controls. It includes functionality for registering forms, adding controls, validating fields, and handling form data.
*
* @class
* @param {WeakMap<AbstractControl, FieldProperties>} controls - A WeakMap to store control properties.
* @param {Map<string, FormGroup>} formRegistry - A Map to store registered forms.
*
* @example
* // Creating a form from components
* const components = [
* { inputs: { name: 'username', type: 'text', required: true } },
* { inputs: { name: 'password', type: 'password', minLength: 8 } }
* ];
* const form = NgxFormService.createFormFromComponents('loginForm', components, true);
*
* // Validating fields
* NgxFormService.validateFields(form);
*
* // Getting form data
* const formData = NgxFormService.getFormData(form);
*
* @mermaid
* sequenceDiagram
* participant C as Component
* participant NFS as NgxFormService
* participant AF as Angular Forms
* C->>NFS: createFormFromComponents()
* NFS->>AF: new FormGroup()
* NFS->>NFS: addFormControl()
* NFS->>AF: addControl()
* NFS-->>C: Return FormGroup
* C->>NFS: validateFields()
* NFS->>AF: markAsTouched(), markAsDirty(), updateValueAndValidity()
* C->>NFS: getFormData()
* NFS->>AF: Get control values
* NFS-->>C: Return form data
*/
export class NgxFormService {
/**
* @description WeakMap that stores control properties for form controls.
* @summary A WeakMap that associates AbstractControl instances with their corresponding FieldProperties.
* This allows the service to track metadata for form controls without creating memory leaks.
*
* @type {WeakMap<AbstractControl, FieldProperties>}
* @private
* @static
* @memberOf NgxFormService
*/
private static controls = new WeakMap<AbstractControl, FieldProperties>();
/**
* @description Registry of form groups indexed by their unique identifiers.
* @summary A Map that stores FormGroup instances with their unique string identifiers.
* This allows global access to registered forms throughout the application.
*
* @type {Map<string, FormGroup>}
* @private
* @static
* @memberOf NgxFormService
*/
private static formRegistry = new Map<string, FormGroup>();
/**
* @description Adds a form to the registry.
* @summary Registers a FormGroup with a unique identifier. Throws an error if the identifier is already in use.
* @param {string} formId - The unique identifier for the form.
* @param {FormGroup} formGroup - The FormGroup to be registered.
* @throws {Error} If a FormGroup with the given id is already registered.
*/
static addRegistry(formId: string, formGroup: FormGroup): void {
if (this.formRegistry.has(formId))
throw new Error(`A FormGroup with id '${formId}' is already registered.`);
this.formRegistry.set(formId, formGroup);
}
/**
* @description Removes a form from the registry.
* @summary Deletes a FormGroup from the registry using its unique identifier.
* @param {string} formId - The unique identifier of the form to be removed.
*/
static removeRegistry(formId: string): void {
this.formRegistry.delete(formId);
}
/**
* @description Resolves the parent group and control name from a path.
* @summary Traverses the form group structure to find the parent group and control name for a given path.
* @param {FormGroup} formGroup - The root FormGroup.
* @param {string} path - The path to the control.
* @return {FormParentGroup} A tuple containing the parent FormGroup and the control name.
*/
private static resolveParentGroup(formGroup: FormGroup, path: string, componentProps: IComponentInput, parentProps: KeyValue): FormParentGroup {
const isMultiple = parentProps?.['multiple'] || parentProps?.['type'] === 'Array' || false;
const parts = path.split('.');
const controlName = parts.pop() as string;
const {childOf} = componentProps
let currentGroup = formGroup;
function setArrayComponentProps(formGroupArray: FormArray) {
const props = (formGroupArray as KeyValue)[AngularEngineKeys.FORM_GROUP_COMPONENT_PROPS] || {};
if(!props[ModelKeys.MODEL][controlName])
props[ModelKeys.MODEL] = Object.assign({}, props[ModelKeys.MODEL], {[controlName]: {...componentProps}});
}
for (const part of parts) {
if (!currentGroup.get(part)) {
const partFormGroup = (isMultiple && part === childOf) ? new FormArray([new FormGroup({})]) : new FormGroup({});
(partFormGroup as KeyValue)[AngularEngineKeys.FORM_GROUP_COMPONENT_PROPS] = {
childOf: childOf || '',
isMultiple: isMultiple,
name: part,
pk: componentProps?.['pk'] || parentProps?.['pk'] || '',
[ModelKeys.MODEL]: {},
} as Partial<FieldProperties> & {model: KeyValue};
if(currentGroup instanceof FormArray) {
(currentGroup as FormArray).push(partFormGroup);
} else {
for(const control of Object.values(partFormGroup.controls)) {
if(control instanceof FormControl)
this.register(control as AbstractControl, componentProps);
}
if(partFormGroup instanceof AbstractControl)
this.register(partFormGroup as AbstractControl, componentProps);
currentGroup.addControl(part, partFormGroup);
}
}
if(childOf && currentGroup instanceof FormArray)
setArrayComponentProps(currentGroup);
currentGroup = currentGroup.get(part) as FormGroup;
}
return [currentGroup, controlName];
}
/**
* @description Retrieves component properties from a FormGroup or FormArray.
* @summary Extracts component properties stored in the form group metadata. If a FormGroup is provided
* and groupArrayName is specified, it will look for the FormArray within the form structure.
*
* @param {FormGroup | FormArray} formGroup - The form group or form array to extract properties from
* @param {string} [key] - Optional key to retrieve a specific property
* @param {string} [groupArrayName] - Optional name of the group array if formGroup is not a FormArray
* @return {Partial<FieldProperties>} The component properties or a specific property if key is provided
*
* @static
* @memberOf NgxFormService
*/
static getComponentPropsFromGroupArray(formGroup: FormGroup | FormArray, key?: string, groupArrayName?: string | undefined): Partial<FieldProperties> {
if(!(formGroup instanceof FormArray) && typeof groupArrayName === Primitives.STRING)
formGroup = formGroup.root.get(groupArrayName as string) as FormArray || {};
const props = (formGroup as KeyValue)?.[AngularEngineKeys.FORM_GROUP_COMPONENT_PROPS] || {};
return (!key ? props : props?.[key]) || {};
}
/**
* @description Adds a new group to a parent FormArray.
* @summary Creates and adds a new FormGroup to the specified parent FormArray based on the
* component properties stored in the parent's metadata. This is used for dynamic form arrays
* where new groups need to be added at runtime.
*
* @param {FormGroup} formGroup - The root form group containing the parent FormArray
* @param {string} parentName - The name of the parent FormArray to add the group to
* @param {number} [index=1] - The index position where the new group should be added
* @return {FormGroup} The newly created and added FormGroup
*
* @static
* @memberOf NgxFormService
*/
static addGroupToParent(formGroup: FormGroup, parentName: string, index: number = 1): FormGroup {
const componentProps = this.getComponentPropsFromGroupArray(formGroup, ModelKeys.MODEL, parentName);
Object.entries(componentProps as KeyValue).forEach(([, value]) => {
return this.addFormControl(formGroup, value, {multiple: true}, index);
});
return this.getGroupFromParent(formGroup, parentName, index);
}
/**
* @description Retrieves a FormGroup from a parent FormArray at the specified index.
* @summary Gets a FormGroup from the specified parent FormArray. If the group doesn't exist
* at the given index, it will create a new one using addGroupToParent.
*
* @param {FormGroup} formGroup - The root form group containing the parent FormArray
* @param {string} parentName - The name of the parent FormArray to retrieve the group from
* @param {number} [index=1] - The index of the group to retrieve
* @return {FormGroup} The FormGroup at the specified index
*
* @static
* @memberOf NgxFormService
*/
static getGroupFromParent(formGroup: FormGroup, parentName: string, index: number = 1): FormGroup {
const childGroup = ((formGroup.get(parentName) || formGroup) as FormArray).at(index);
if(childGroup instanceof FormGroup)
return childGroup;
return this.addGroupToParent(formGroup, parentName, index);
}
/**
* @description Checks if a value is unique within a FormArray group.
* @summary Validates that the primary key value in a FormGroup is unique among all groups
* in the parent FormArray. The uniqueness check behavior differs based on the operation type.
*
* @param {FormGroup} formGroup - The FormGroup to check for uniqueness
* @param {number} index - The index of the current group within the FormArray
* @param {OperationKeys} [operation=OperationKeys.CREATE] - The type of operation being performed
* @return {boolean} True if the value is unique, false otherwise
*
* @static
* @memberOf NgxFormService
*/
static isUniqueOnGroup(formGroup: FormGroup, index: number, operation: OperationKeys = OperationKeys.CREATE): boolean {
const formGroupArray = formGroup.parent as FormArray;
const pk = this.getComponentPropsFromGroupArray(formGroupArray, BaseComponentProps.PK as string) as string;
const controlName = Object.keys(formGroup.controls)[0];
// only check for unique if is the pk control
if(controlName !== pk)
return true;
const controlValue = cleanSpaces(`${formGroup.get(pk)?.value}`, true);
if(operation === OperationKeys.CREATE)
return !formGroupArray.controls.some((group, i) => i !== index && cleanSpaces(`${group.get(pk)?.value}`, true) === controlValue);
return !formGroupArray.controls.some((group, i) =>
i !== index && controlValue === cleanSpaces(`${group.get(pk)?.value}`, true)
);
}
/**
* @description Enables all controls within a FormGroup or FormArray.
* @summary Recursively enables all form controls within the provided FormGroup or FormArray.
* This is useful for making all controls interactive after they have been disabled.
*
* @param {FormArray | FormGroup} formGroup - The FormGroup or FormArray to enable all controls for
* @return {void}
*
* @static
* @memberOf NgxFormService
*/
static enableAllGroupControls(formGroup: FormArray | FormGroup): void {
Object.keys(formGroup.controls).forEach(key => {
const control = formGroup.get(key);
if (control instanceof FormArray) {
control.controls.forEach(child => {
if (child instanceof FormGroup) {
child.enable({ emitEvent: false });
child.updateValueAndValidity({ emitEvent: true });
}
});
}
});
}
/**
* @description Adds a form control to a form group based on component properties.
* @summary Creates and configures a FormControl within the specified FormGroup using the provided
* component properties. Handles nested paths, multiple controls (FormArrays), and control registration.
* This method supports complex form structures with nested groups and arrays.
*
* @param {FormGroup} formGroup - The form group to add the control to
* @param {IComponentInput} componentProps - The component properties defining the control configuration
* @param {KeyValue} [parentProps={}] - Properties from the parent component for context
* @param {number} [index=0] - The index for multiple controls in FormArrays
* @return {void}
*
* @private
* @static
* @memberOf NgxFormService
*/
private static addFormControl(formGroup: FormGroup, componentProps: IComponentInput, parentProps: KeyValue = {}, index: number = 0): void {
const isMultiple = parentProps?.['multiple'] || parentProps?.['type'] === 'Array' || false;
const { name, childOf, } = componentProps;
if(isMultiple)
componentProps['pk'] = componentProps['pk'] || parentProps?.['pk'] || '';
const fullPath = childOf ? isMultiple ? `${childOf}.${index}.${name}` : `${childOf}.${name}` : name;
const [parentGroup, controlName] = this.resolveParentGroup(formGroup, fullPath, componentProps, parentProps);
if (!parentGroup.get(controlName)) {
const control = NgxFormService.fromProps(
componentProps,
componentProps.updateMode || 'change',
);
NgxFormService.register(control, componentProps);
parentGroup.addControl(controlName, control);
}
componentProps['formGroup'] = parentGroup;
componentProps['formControl'] = parentGroup.get(controlName) as FormControl;
componentProps['multiple'] = isMultiple
}
/**
* @description Retrieves a control from a registered form.
* @summary Finds and returns an AbstractControl from a registered form using the form id and optional path.
* @param {string} formId - The unique identifier of the form.
* @param {string} [path] - The path to the control within the form.
* @return {AbstractControl} The requested AbstractControl.
* @throws {Error} If the form is not found in the registry or the control is not found in the form.
*/
static getControlFromForm(formId: string, path?: string): AbstractControl {
const form = this.formRegistry.get(formId);
if (!form)
throw new Error(`Form with id '${formId}' not found in the registry.`);
if (!path)
return form;
const control = form.get(path);
if (!control)
throw new Error(`Control with path '${path}' not found in form '${formId}'.`);
return control;
}
/**
* @description Creates a form from component configurations.
* @summary Generates a FormGroup based on an array of component configurations and optionally registers it.
* @param {string} id - The unique identifier for the form.
* @param {IComponentConfig[]} components - An array of component configurations.
* @param {boolean} [registry=false] - Whether to register the created form.
* @return {FormGroup} The created FormGroup.
*/
static createFormFromComponents(id: string, components: IComponentConfig[], registry: boolean = false): FormGroup {
const form = new FormGroup({});
components.forEach(component => {
this.addFormControl(form, component.inputs);
});
if (registry)
this.addRegistry(id, form);
return form;
}
/**
* @description Adds a control to a form based on component properties.
* @summary Creates and adds a form control to a form (existing or new) based on the provided component properties.
* @param {string} id - The unique identifier of the form.
* @param {FieldProperties} componentProperties - The properties of the component to create the control from.
* @return {AbstractControl} The form or created control.
*/
static addControlFromProps(id: string, componentProperties: FieldProperties, parentProps?: FieldProperties): AbstractControl {
const form = this.formRegistry.get(id) ?? new FormGroup({});
if (!this.formRegistry.has(id))
this.addRegistry(id, form);
if (componentProperties.path)
this.addFormControl(form, componentProperties, parentProps);
return form;
}
/**
* @description Retrieves form data from a FormGroup.
* @summary Extracts and processes the data from a FormGroup, handling different input types and nested form groups.
* @param {FormGroup} formGroup - The FormGroup to extract data from.
* @return {Record<string, unknown>} An object containing the form data.
*/
static getFormData(formGroup: FormGroup): Record<string, unknown> {
const data: Record<string, unknown> = {};
for (const key in formGroup.controls) {
const control = formGroup.controls[key];
const parentProps = NgxFormService.getPropsFromControl(formGroup as FormGroup | FormArray);
if (!(control instanceof FormControl)) {
const value = NgxFormService.getFormData(control as FormGroup);
const isValid = control.valid;
if(parentProps.multiple) {
if(isValid) {
data[key] = value;
} else {
this.reset(control as FormControl);
}
continue;
}
data[key] = value;
continue;
}
const props = NgxFormService.getPropsFromControl(control as FormControl | FormArray);
let value = control.value;
if (!HTML5CheckTypes.includes(props['type'])) {
switch (props['type']) {
case HTML5InputTypes.NUMBER:
value = parseToNumber(value);
break;
case HTML5InputTypes.DATE:
case HTML5InputTypes.DATETIME_LOCAL:
value = new Date(value);
break;
default:
value = escapeHtml(value);
}
}
data[key] = value;
}
NgxFormService.enableAllGroupControls(formGroup as FormGroup);
return data;
}
/**
* @description Validates fields in a form control or form group.
* @summary Recursively validates all fields in a form control or form group, marking them as touched and dirty.
* @param {AbstractControl} control - The control or form group to validate.
* @param {string} [path] - The path to the control within the form.
* @return {boolean} True if all fields are valid, false otherwise.
* @throws {Error} If no control is found at the specified path or if the control type is unknown.
*/
static validateFields(control: AbstractControl, pk?: string, path?: string): boolean {
control = path ? control.get(path) as AbstractControl : control;
if (!control)
throw new Error(`No control found at path: ${path || 'root'}.`);
const isAllowed = [FormArray, FormGroup, FormControl].some(type => control instanceof type);
if (!isAllowed)
throw new Error(`Unknown control type at: ${path || 'root'}`);
control.markAsTouched();
control.markAsDirty();
control.updateValueAndValidity({ emitEvent: true });
if (control instanceof FormGroup) {
Object.values(control.controls).forEach(childControl => {
this.validateFields(childControl);
});
}
if (control instanceof FormArray) {
const totalGroups = control.length;
const hasValid = control.controls.some(control => control.valid);
if(totalGroups > 1 && hasValid) {
for (let i = control.length - 1; i >= 0; i--) {
const childControl = control.at(i);
// disable no valid groups on array
if (!childControl.valid) {
(childControl.parent as FormGroup).setErrors(null);
(childControl.parent as FormGroup).updateValueAndValidity({ emitEvent: true });
childControl.disable();
} else {
this.validateFields(childControl);
}
}
} else {
Object.values(control.controls).forEach(childControl => {
this.validateFields(childControl);
});
}
}
return control.valid;
}
/**
* @description Generates validators from component properties.
* @summary Creates an array of ValidatorFn based on the supported validation keys in the component properties.
* @param {FieldProperties} props - The component properties.
* @return {ValidatorFn[]} An array of validator functions.
*/
private static validatorsFromProps(props: FieldProperties): ValidatorFn[] {
const supportedValidationKeys = Validation.keys();
return Object.keys(props)
.filter((k: string) => supportedValidationKeys.includes(k))
.map((k: string) => {
return ValidatorFactory.spawn(props, k);
});
}
/**
* @description Creates a FormControl from component properties.
* @summary Generates a FormControl with validators based on the provided component properties.
* @param {FieldProperties} props - The component properties.
* @param {FieldUpdateMode} [updateMode='change'] - The update mode for the control.
* @return {FormControl} The created FormControl.
*/
static fromProps(props: FieldProperties, updateMode: FieldUpdateMode = 'change'): FormControl {
const validators = this.validatorsFromProps(props);
const composed = validators.length ? Validators.compose(validators) : null;
return new FormControl(
{
value:
props.value && props.type !== HTML5InputTypes.CHECKBOX
? props.type === HTML5InputTypes.DATE
? !isValidDate(parseDate(props.format as string, props.value as string))
? undefined : props.value :
(props.value as unknown) : undefined,
disabled: props.disabled,
},
{
validators: composed,
updateOn: updateMode,
},
);
}
/**
* @description Retrieves properties from a FormControl.
* @summary Gets the FieldProperties associated with a FormControl from the internal WeakMap.
* @param {FormControl} control - The FormControl to get properties for.
* @return {FieldProperties} The properties associated with the control.
*/
static getPropsFromControl(control: FormControl | FormArray | FormGroup): FieldProperties {
return this.controls.get(control) || {} as FieldProperties;
}
/**
* @description Finds a parent element with a specific tag.
* @summary Traverses up the DOM tree to find the nearest parent element with the specified tag.
* @param {HTMLElement} el - The starting element.
* @param {string} tag - The tag name to search for.
* @return {HTMLElement} The found parent element.
* @throws {Error} If no parent with the specified tag is found.
*/
static getParentEl(el: HTMLElement, tag: string): HTMLElement {
let parent: HTMLElement | null;
while ((parent = el.parentElement) !== null) {
if (parent.tagName.toLowerCase() === tag.toLowerCase()) {
return parent;
}
el = parent;
}
throw new Error(
`No parent with the tag ${tag} was found for provided element`,
);
}
/**
* @description Registers a control with its properties.
* @summary Associates a control with its properties in the internal WeakMap.
* @param {AbstractControl} control - The control to register.
* @param {FieldProperties} props - The properties to associate with the control.
*/
static register(control: AbstractControl, props: FieldProperties) {
this.controls.set(control, props);
}
/**
* @description Unregisters a control.
* @summary Removes a control and its associated properties from the internal WeakMap.
* @param {AbstractControl} control - The control to unregister.
* @return {boolean} True if the control was successfully unregistered, false otherwise.
*/
static unregister(control: AbstractControl): boolean {
return this.controls.delete(control);
}
/**
* @description Resets a form group.
* @summary Recursively resets all controls in a form group, clearing values, errors, and marking them as pristine and untouched.
* @param {FormGroup} formGroup - The form group to reset.
*/
static reset(formGroup: FormGroup | FormControl): void {
if(formGroup instanceof FormControl) {
const control = formGroup as FormControl;
const { type } = NgxFormService.getPropsFromControl(control);
if (!HTML5CheckTypes.includes(type))
control.setValue("");
control.markAsPristine();
control.markAsUntouched();
control.setErrors(null);
control.updateValueAndValidity();
} else {
for (const key in formGroup.controls) {
const control = formGroup.controls[key];
NgxFormService.reset(control as FormControl);
continue;
}
}
}
}
Source