libs/core/dynamic-form/src/base/base.component.ts
Base component class for formfield components.
A formfield is a labeled input of some kind that lets the user input or select a single
value. Components deriving from this base class work exclusively with reactive forms, so
a formfield's model is therefore always an AbstractControl
, which might bei either a concrete
FormControl
or a more complex subclass like a FormGroup
or FormArray
.
Examples are labelled text inputs, dropdowns, checkboxes, groups of radio buttons and so on.
Formfield-like components should extend from this class wherever possible. This ensures a more consistent API for inputs & outputs. Furthermore, it makes them compatible with the dynamic formfield component.
This base class takes care of processing the validator configurations and adding the necessary
validators to the underlying AbstractControl
. Derived formfield components should use the
errorMessages
variable to retrieve the error text of whichever validator is failing and
display it to the user.
Properties |
constructor()
|
aclResource |
Type : string
|
control |
Type : InputSignal<AbstractControl> | InputSignal<UntypedFormControl> | InputSignal<UntypedFormGroup> | InputSignal<undefined>
|
Default value : input<AbstractControl>(new UntypedFormControl())
|
The If an existing If no If a form component doesn't use the Note that if |
formAclPath |
Default value : input<string>()
|
isRetailChannel |
Default value : input<boolean>()
|
validationConfigs |
Default value : input<ValidationConfig[] | undefined>()
|
import { ValidationConfig } from '@allianz/taly-core';
import { dasherize } from '@angular-devkit/core/src/utils/strings';
import {
Component,
DestroyRef,
effect,
inject,
input,
InputSignal,
OnInit,
output
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AbstractControl,
UntypedFormArray,
UntypedFormControl,
UntypedFormGroup
} from '@angular/forms';
import { debounceTime, ReplaySubject, skip } from 'rxjs';
import { DfValueProviderService } from '../services/value-provider/value-provider.service';
import { DfBaseConfig, type DfEventPayload } from './base.model';
let lineBreakCounter = 0;
/**
* Base component class for formfield components.
*
* A formfield is a labeled input of some kind that lets the user input or select a single
* value. Components deriving from this base class work exclusively with reactive forms, so
* a formfield's model is therefore always an `AbstractControl`, which might bei either a concrete
* `FormControl` or a more complex subclass like a `FormGroup` or `FormArray`.
*
* Examples are labelled text inputs, dropdowns, checkboxes, groups of radio buttons and so on.
*
* Formfield-like components should extend from this class wherever possible. This ensures a
* more consistent API for inputs & outputs. Furthermore, it makes them compatible with the
* dynamic formfield component.
*
* This base class takes care of processing the validator configurations and adding the necessary
* validators to the underlying `AbstractControl`. Derived formfield components should use the
* `errorMessages` variable to retrieve the error text of whichever validator is failing and
* display it to the user.
*/
@Component({
template: '',
standalone: false
})
export class DfBaseComponent<C extends DfBaseConfig = DfBaseConfig> implements OnInit {
private valueProviderService: DfValueProviderService | null = inject(DfValueProviderService, {
optional: true
});
protected destroyRef = inject(DestroyRef);
aclResource!: string;
/**
* The configuration object for this formfield.
*
* Note that derived formfield components should extend the `DfBaseConfig` config interface
* as needed and expose that their own config interface.
*/
config: InputSignal<C> = input.required<C>();
/**
* The `AbstractControl` (to be) associated with this formfield.
*
* If an existing `AbstractControl` is supplied, this component will associate itself
* with it, set its initial value (if given in the config), and add
* any validator functions specified in the config.
*
* If no `AbstractControl` instance is supplied, this component will create a `FormControl` itself.
*
* If a form component doesn't use the `AbstractControl`, it should be set to undefined
* in the extending class.
*
* Note that if `undefined` is explicitly provided e.g. in the `DfHeadlineComponent` or `DfParagraphComponent`, the component will NOT create a `FormControl` itself.
*/
control:
| InputSignal<AbstractControl>
| InputSignal<UntypedFormControl>
| InputSignal<UntypedFormGroup>
| InputSignal<undefined> = input<AbstractControl>(new UntypedFormControl());
validationConfigs = input<ValidationConfig[] | undefined>();
formAclPath = input<string>();
isRetailChannel = input<boolean>();
/**
* Emits the `AbstractControl` associated with this formfield, once it has been fully
* configured (i.e. its initial value and validators have been
* added as per the config).
*
* Applications that need to further process this `AbstractControl` (e.g. to add more
* validators) should therefore wait for this event to be emitted.
*/
readonly controlChanged = output<AbstractControl | undefined>();
/**
* Emits when events associated to the form control happen.
*
* The emitted object contains the data necessary to uniquely identify the event (field id and event type). It also contains the event data.
*/
readonly formEvent = output<DfEventPayload>();
/**
* Additionally to the controlChanged EventEmitter a ReplaySubject is provided.
* In difference to the EventEmitter, this one always contains the last emitted value,
* which allows to check if a component was initialized after the initialization already happened.
* (An event will be gone at that point in time.)
*/
componentOrControlInitFinished = new ReplaySubject<void>(1);
/**
* By default, the `AbstractControl` is configured in ngOnInit(), however
* in some cases this causes an `ExpressionChangedAfterItHasBeenCheckedError`.
*
* If this is set to true, the setup will **not** be run automatically. It is then
* the derived component's responsibility to run call `setupFormControl()` at the
* appropriate time.
*/
public deferSetupControl = false;
constructor() {
effect(() => {
const configValue = this.config();
if (configValue?.type !== 'LINE_BREAK') {
this.aclResource = dasherize(configValue.id);
} else {
this.aclResource = `line-break-${lineBreakCounter++}`;
}
});
}
/**
* Sets up the `AbstractControl` using the config.
*
* Unless `deferSetupControl` was set to `true`, in which
* the the only action that is taken is to create a new concrete
* `AbstractControl` instance if none was provided as an input.
*
* This is needed as the template cannot render unless
* `control` is defined.
*/
ngOnInit() {
this.controlChanged.subscribe(() => {
// The controlChanged output was an asynchronous EventEmitter in the past. With the migration
// to an output function, this changed to be synchronous.
// Using a setTimeout here ensures that the behavior is the same as with the previous asynchronous EventEmitter.
// Unlike the subscribe in the formfield component, this one did not cause an ExpressionChangedAfterItHasBeenCheckedError,
// but this still ensures that the behavior is the same as before.
setTimeout(() => {
this.componentOrControlInitFinished.next();
}, 0);
});
if (!this.deferSetupControl) {
this.setupFormControl();
}
}
/**
* Sets this field's form control value to the initial value from the config.
*
* If the group does not already contain a form control for this field, a
* new one is created and added.
*/
protected setupFormControl = () => {
// Use patchValue instead of setValue in order to support incomplete
// values supplied via config for complex form groups.
// Perform any additional setup that may be required
this.doAdditionalSetup();
const configValue = this.config();
// If there is no configured value, we use a default value depending on
// the concrete type of the AbstractControl.
// Otherwise the form control won't register any kind of update to the view.
if (!('value' in configValue)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let defaultValue: any;
if (this.control() instanceof UntypedFormGroup) {
defaultValue = {};
} else if (this.control() instanceof UntypedFormArray) {
defaultValue = [];
} else {
defaultValue = null;
}
this.control()?.patchValue(defaultValue);
this.controlChanged.emit(this.control());
return;
}
if (this.valueProviderService) {
this.valueProviderService
.getValue(configValue.value)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.control()?.patchValue(value);
});
} else {
this.control()?.patchValue(configValue.value);
}
this.controlChanged.emit(this.control());
};
/**
* Is called when this field's form control is set up.
*
* The default implementation is a no-op. Sub-classes can override
* it with any extra setup steps they need to perform.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected doAdditionalSetup() {}
/**
* Helper function to emit an event from the form field.
* Mostly meant for Dynamic Form custom components to emit their custom events.
*
* @param {string} type The type of the event, as specified in the configuration of the dynamic form field
* @param {unknown} [data] The event data
*/
protected emitFormEvent(type: string, data?: unknown) {
this.formEvent.emit({
type,
fieldIdConfig: this.config().id,
data
});
}
/**
* Helper function to emit an event when the value of the form control associated to the form field changes.
*/
protected emitFormControlEventOnValueChanges() {
const controlValue = this.control();
if (!controlValue) return;
// Skip initialization event
controlValue.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef), skip(1))
.subscribe((newValue) => this.emitFormEvent('onValueChangesEvent', newValue));
}
/**
* Helper function to emit an event when the value of the form control associated to the form field changes.
*
* @param {number} debounceTimeInMs The debouncing time applied to prevent too many emissions. By default it is 300ms.
*/
protected emitFormControlEventOnValueChangesWithDebounce(debounceTimeInMs = 300) {
const controlValue = this.control();
if (!controlValue) return;
// Skip initialisation event
controlValue.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef), skip(1), debounceTime(debounceTimeInMs))
.subscribe((newValue) => this.emitFormEvent('onValueChangesEvent', newValue));
}
}