/**
* @module lib/engine/NgxFormFieldDirective
* @description Base directive for CRUD form fields in Decaf Angular applications.
* @summary Provides the NgxFormFieldDirective abstract class that implements ControlValueAccessor
* and FieldProperties to enable form field integration with Angular's reactive forms system.
* This directive handles form control lifecycle, validation, multi-entry forms, and CRUD operations.
*/
import { CrudOperationKeys, FieldProperties, RenderingError } from '@decaf-ts/ui-decorators';
import { FormParent, KeyValue, PossibleInputTypes } from './types';
import { CrudOperations, InternalError, OperationKeys } from '@decaf-ts/db-decorators';
import { ControlValueAccessor, FormArray, FormControl, FormGroup } from '@angular/forms';
import { Directive, Inject, Input, OnChanges, SimpleChanges } from '@angular/core';
import { NgxFormService } from '../services/NgxFormService';
import { sf } from '@decaf-ts/decorator-validation';
import { ComponentEventNames } from './constants';
import { FunctionLike } from './types';
import { NgxComponentDirective } from './NgxComponentDirective';
import { CPTKN } from '../for-angular-common.module';
/**
* @description Abstract base directive for CRUD form fields in Angular applications.
* @summary Provides the foundation for all form field components in Decaf applications by implementing
* Angular's ControlValueAccessor interface and FieldProperties for validation. This directive manages
* form control integration, validation state, multi-entry forms (FormArrays), and CRUD operation context.
* It handles form group lifecycle, error messaging, change detection, and parent-child form relationships.
* Extend this class to create custom form field components that seamlessly integrate with Angular's
* reactive forms and Decaf's validation system.
* @class NgxFormFieldDirective
* @extends {NgxComponentDirective}
* @implements {ControlValueAccessor}
* @implements {FieldProperties}
* @example
* ```typescript
* @Component({
* selector: 'app-text-field',
* templateUrl: './text-field.component.html',
* providers: [{
* provide: NG_VALUE_ACCESSOR,
* useExisting: forwardRef(() => TextFieldComponent),
* multi: true
* }]
* })
* export class TextFieldComponent extends NgxFormFieldDirective {
* constructor() {
* super();
* }
* }
* ```
*/
@Directive()
export abstract class NgxFormFieldDirective extends NgxComponentDirective implements OnChanges, ControlValueAccessor, FieldProperties {
/**
* @description Index of the currently active form group in a form array.
* @summary When working with multiple form groups (form arrays), this indicates
* which form group is currently active or being edited. This is used to manage
* focus and data binding in multi-entry scenarios.
* @type {number}
* @default 0
* @public
*/
@Input()
activeFormGroupIndex: number = 0;
@Input({ required: true })
override operation: CrudOperations = OperationKeys.CREATE;
/**
* @description Parent form container for this field.
* @summary Reference to the parent FormGroup or FormArray that contains this field.
* When this field is part of a multi-entry structure, this contains the FormArray
* with all form groups. This enables management of multiple instances of the same
* field structure within a single form.
* @type {FormParent}
* @public
*/
@Input()
parentForm!: FormParent;
/**
* @description Field mapping configuration for options.
* @summary Defines how fields from the data model should be mapped to properties used by the component.
* This allows for flexible data binding between the model and the component's display logic.
* Can be either a key-value mapping object or a function that performs the mapping.
* @type {KeyValue | FunctionLike}
* @public
*/
@Input()
optionsMapper: KeyValue | FunctionLike = {};
/**
* @description Angular FormGroup instance for the field.
* @summary The FormGroup that contains this field's FormControl. Used for managing
* the field's validation state and value within the reactive forms structure.
* @type {FormGroup | undefined}
* @public
*/
formGroup!: FormGroup | undefined;
/**
* @description Angular FormControl instance for this field.
* @summary The FormControl that manages this field's value, validation state, and user interactions.
* @type {FormControl}
* @public
*/
formControl!: FormControl;
/**
* @description Dot-separated path to this field in the form structure.
* @summary Used to locate this field within nested form structures.
* @type {string}
* @public
*/
path!: string;
/**
* @description The input type of this field.
* @summary Determines the HTML input type or component type to render.
* @type {PossibleInputTypes}
* @public
*/
type!: PossibleInputTypes ;
/**
* @description Whether the field is disabled.
* @summary When true, the field cannot be edited by the user.
* @type {boolean}
* @public
*/
disabled?: boolean;
/**
* @description Page number for multi-page forms.
* @summary Indicates which page this field belongs to in a multi-page form structure.
* @type {number}
* @public
*/
page!: number;
// Validation properties
/**
* @description Date/time format string for parsing and display.
* @type {string}
* @public
*/
format?: string;
/**
* @description Controls field visibility based on CRUD operations.
* @summary Can be a boolean or an array of operation keys where the field should be hidden.
* @type {boolean | CrudOperationKeys[]}
* @public
*/
hidden?: boolean | CrudOperationKeys[];
/**
* @description Maximum value or date allowed.
* @type {number | Date}
* @public
*/
max?: number | Date;
/**
* @description Maximum length for text input.
* @type {number}
* @public
*/
maxlength?: number;
/**
* @description Minimum value or date allowed.
* @type {number | Date}
* @public
*/
min?: number | Date;
/**
* @description Minimum length for text input.
* @type {number}
* @public
*/
minlength?: number;
/**
* @description Regex pattern for validation.
* @type {string | undefined}
* @public
*/
pattern?: string | undefined;
/**
* @description Whether the field is read-only.
* @type {boolean}
* @public
*/
readonly?: boolean;
/**
* @description Whether the field is required.
* @type {boolean}
* @public
*/
required?: boolean;
/**
* @description Step value for numeric inputs.
* @type {number}
* @public
*/
step?: number;
/**
* @description Field name that this field's value must equal.
* @type {string}
* @public
*/
equals?: string;
/**
* @description Field name that this field's value must differ from.
* @type {string}
* @public
*/
different?: string;
/**
* @description Field name that this field's value must be less than.
* @type {string}
* @public
*/
lessThan?: string;
/**
* @description Field name that this field's value must be less than or equal to.
* @type {string}
* @public
*/
lessThanOrEqual?: string;
/**
* @description Field name that this field's value must be greater than.
* @type {string}
* @public
*/
greaterThan?: string;
/**
* @description Field name that this field's value must be greater than or equal to.
* @type {string}
* @public
*/
greaterThanOrEqual?: string;
/**
* @description Current value of the field.
* @summary Can be a string, number, date, or array of strings for multi-select fields.
* @type {string | number | Date | string[]}
* @public
*/
override value!: string | number | Date | string[];
/**
* @description Whether the field supports multiple values.
* @summary When true, the field is rendered as part of a FormArray structure.
* @type {boolean}
* @public
*/
multiple!: boolean;
/**
* @description Flag tracking if validation error event has been dispatched.
* @summary Prevents duplicate validation error events from being dispatched.
* @type {boolean}
* @private
*/
private validationErrorEventDispatched: boolean = false;
/**
* @description Reference to the parent HTML element.
* @summary Used for DOM manipulation and event handling.
* @type {HTMLElement}
* @protected
*/
protected parent?: HTMLElement;
// eslint-disable-next-line @angular-eslint/prefer-inject
constructor(@Inject(CPTKN) componentName: string = "ComponentCrudField") {
super(componentName);
}
/**
* @description Gets the currently active form group based on context.
* @summary Returns the appropriate FormGroup based on whether this field supports
* multiple values. For single-value fields, returns the main form group.
* For multi-value fields, returns the form group at the active index from the parent FormArray.
* If no formGroup is set, returns the parent of the formControl.
* @return {FormGroup} The currently active FormGroup for this field
* @public
*/
get activeFormGroup(): FormGroup {
if (!this.formGroup)
return this.formControl.parent as FormGroup;
if (this.multiple) {
if (this.formGroup instanceof FormArray)
return this.formGroup.at(this.activeFormGroupIndex) as FormGroup;
return this.formGroup;
}
return this.formGroup as FormGroup;
}
/**
* @description String formatting utility function.
* @summary Provides access to the sf (string format) function for formatting error messages
* and other string templates. Used primarily for localizing and parameterizing validation messages.
* @type {function(string, ...string): string}
* @public
*/
sf = sf;
/**
* @description Callback function invoked when the field value changes.
* @summary Function registered by Angular's forms system through registerOnChange.
* Called automatically when the field's value is updated to notify the form of the change.
* @type {function(): unknown}
* @public
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange: () => unknown = () => {};
/**
* @description Callback function invoked when the field is touched.
* @summary Function registered by Angular's forms system through registerOnTouched.
* Called when the field is blurred or otherwise marked as touched.
* @type {function(): unknown}
* @public
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
onTouch: () => unknown = () => {};
/**
* @description Writes a value to the form field.
* @summary Part of Angular's ControlValueAccessor interface. Sets the field's value
* when the form programmatically updates it. This is called by Angular forms when
* the model value changes.
* @param {string} obj - The value to be set
* @return {void}
* @public
*/
writeValue(obj: string): void {
this.value = obj;
}
/**
* @description Registers the onChange callback function.
* @summary Part of Angular's ControlValueAccessor interface. Stores the function
* that Angular forms provides to be called when the field value changes.
* @param {function(): unknown} fn - The function to be called on change
* @return {void}
* @public
*/
registerOnChange(fn: () => unknown): void {
this.onChange = fn;
}
/**
* @description Registers the onTouched callback function.
* @summary Part of Angular's ControlValueAccessor interface. Stores the function
* that Angular forms provides to be called when the field is touched/blurred.
* @param {function(): unknown} fn - The function to be called on touch
* @return {void}
* @public
*/
registerOnTouched(fn: () => unknown): void {
this.onTouch = fn;
}
/**
* @description Sets the disabled state of the field.
* @summary Part of Angular's ControlValueAccessor interface. Called by Angular forms
* when the disabled state of the control changes.
* @param {boolean} isDisabled - Whether the field should be disabled
* @return {void}
* @public
*/
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/**
* @description Performs setup after the view has been initialized.
* @summary Retrieves and returns the parent HTML element based on the current CRUD operation.
* For READ and DELETE operations, returns the immediate parent element. For CREATE and UPDATE
* operations, finds the parent div element and registers it with the form service.
* @return {HTMLElement} The parent element of the field
* @throws {RenderingError} If unable to retrieve parent form element for CREATE/UPDATE operations
* @throws {InternalError} If the operation is invalid
* @public
*/
afterViewInit(): HTMLElement {
this.checkDarkMode();
let parent: HTMLElement;
if (this.component?.nativeElement)
this.isModalChild = this.component.nativeElement.closest('ion-modal') ? true : false;
switch (this.operation) {
case OperationKeys.READ:
case OperationKeys.DELETE:
return this.component.nativeElement.parentElement;
case OperationKeys.CREATE:
case OperationKeys.UPDATE:
try {
parent = NgxFormService.getParentEl(this.component.nativeElement, 'div');
} catch (e: unknown) {
throw new RenderingError(`Unable to retrieve parent form element for the ${this.operation}: ${e instanceof Error ? e.message : e}`);
}
// NgxFormService.register(parent.id, this.formGroup, this as AngularFieldDefinition);
return parent;
default:
throw new InternalError(`Invalid operation: ${this.operation}`);
}
}
/**
* @description Angular lifecycle hook for detecting input property changes.
* @summary Overrides the parent ngOnChanges to handle changes to activeFormGroupIndex and value.
* When activeFormGroupIndex changes in a multiple field scenario, updates the active form group
* and form control. When value changes, updates the form control value. Delegates to parent
* implementation for initial change detection.
* @param {SimpleChanges} changes - Object containing the changed properties
* @return {void}
* @public
*/
override async ngOnChanges(changes: SimpleChanges): Promise<void> {
if (!this.initialized)
await super.ngOnChanges(changes);
if (changes['activeFormGroupIndex'] && this.multiple &&
!changes['activeFormGroupIndex'].isFirstChange() && changes['activeFormGroupIndex'].currentValue !== this.activeFormGroupIndex) {
this.activeFormGroupIndex = changes['activeFormGroupIndex'].currentValue;
this.formGroup = this.activeFormGroup;
this.formControl = this.formGroup.get(this.name) as FormControl;
}
if (changes['value'] && !changes['value'].isFirstChange()
&& (changes['value'].currentValue !== undefined && changes['value'].currentValue !== this.value))
this.setValue(changes['value'].currentValue);
}
/**
* @description Cleanup logic when the component is destroyed.
* @summary Unregisters the form group from the form service to prevent memory leaks
* and clean up form references.
* @return {void}
* @public
*/
onDestroy(): void {
if (this.formGroup)
NgxFormService.unregister(this.formGroup);
}
/**
* @description Sets the value of the form control.
* @summary Updates the form control's value and triggers validation. This is used
* when the value needs to be programmatically updated from outside the form control.
* @param {unknown} value - The value to set
* @return {void}
* @public
*/
setValue(value: unknown): void {
this.formControl.setValue(value);
this.formControl.updateValueAndValidity();
}
handleModalChildChanges() {
if (this.isModalChild)
this.changeDetectorRef.detectChanges();
}
/**
* @description Retrieves validation error messages for the field.
* @summary Checks the form control for validation errors and returns formatted error messages.
* If errors exist, dispatches a validation error event to parent accordion components.
* Error messages are translated and formatted with relevant field properties.
* @param {HTMLElement} parent - The parent HTML element used to find accordion components
* @return {string | void} Formatted error message string, or void if no errors
* @public
*/
getErrors(parent: HTMLElement): string | void {
const formControl = this.formControl;
if (formControl) {
const accordionComponent = parent.closest('ngx-decaf-fieldset')?.querySelector('ion-accordion-group');
if ((!formControl.pristine || formControl.touched) && !formControl.valid) {
const errors: Record<string, string>[] = Object.keys(formControl.errors ?? {}).map(key => ({
key: key,
message: key,
}));
if (errors.length) {
if (accordionComponent && !this.validationErrorEventDispatched) {
const validationErrorEvent = new CustomEvent(ComponentEventNames.VALIDATION_ERROR, {
detail: {fieldName: this.name, hasErrors: true},
bubbles: true
});
accordionComponent.dispatchEvent(validationErrorEvent);
this.validationErrorEventDispatched = true;
}
}
for(const error of errors) {
const instance = this as KeyValue;
return `* ${ this.translateService.instant(`errors.${error?.['message']}`, {"0": `${instance[error?.['key']] ?? ""}`})}`;
}
}
}
}
}
Source