|
isRetailChannel
|
Type : boolean
|
|
Required : true
|
|
|
HostBindings
|
class.df-spacing-none
|
Type : boolean
|
|
|
|
class.df-spacing-xs
|
Type : boolean
|
|
|
Defines the spacing of the formfield.
|
|
class.df-spacing-xxl
|
Type : boolean
|
|
|
|
class.has-hidden-child
|
Type : any
|
|
|
|
componentOrControlInitFinished
|
Type : unknown
|
Default value : new ReplaySubject<void>(1)
|
|
|
Additionally to the groupChanged 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.)
This subject will fire with an undefined value, after the configuration was passed to the component,
if the component doesn't have a form control.
|
|
Optional
containerRef
|
Type : ViewContainerRef
|
Decorators :
@ViewChild('container', {read: ViewContainerRef, static: true})
|
|
|
Accessors
|
hasHiddenChildClass
|
gethasHiddenChildClass()
|
|
|
|
isSpacingXS
|
getisSpacingXS()
|
|
|
Defines the spacing of the formfield.
|
|
isSpacingS
|
getisSpacingS()
|
|
|
|
isSpacingM
|
getisSpacingM()
|
|
|
|
isSpacingL
|
getisSpacingL()
|
|
|
|
isSpacingXL
|
getisSpacingXL()
|
|
|
|
isSpacingXXL
|
getisSpacingXXL()
|
|
|
|
isSpacingNone
|
getisSpacingNone()
|
|
|
import { ValidationConfig } from '@allianz/taly-core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentRef,
effect,
HostBinding,
Injector,
input,
OnDestroy,
OnInit,
output,
signal,
Signal,
ViewChild,
ViewContainerRef,
WritableSignal,
inject
} from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { AbstractControl, FormGroupDirective, UntypedFormGroup } from '@angular/forms';
import { ReplaySubject, Subject, takeUntil } from 'rxjs';
import { DfBaseComponent } from '../base/base.component';
import { DfBaseConfig, type DfEventPayload, DfFormfieldSpacing } from '../base/base.model';
import { DfComponentLoaderService } from '../services/component-loader/component-loader.service';
import { AclExtendedFormGroup } from '@allianz/taly-acl/form-support';
interface DfHideable {
readonly isHidden: Signal<boolean>;
}
function isHideable(obj: unknown): obj is DfHideable {
return (
obj != null &&
typeof obj === 'object' &&
'isHidden' in obj &&
typeof (obj as DfHideable).isHidden === 'function'
);
}
@Component({
selector: 'df-formfield',
templateUrl: './formfield.component.html',
styleUrls: ['./formfield.component.scss'],
host: {
'[attr.data-df-id]': 'config()?.id',
'[attr.data-df-type]': 'config()?.type'
},
standalone: false,
// This component dynamically creates form field components via ViewContainerRef. It must use
// Eager change detection so that user-made form fields are checked properly.
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
changeDetection: ChangeDetectionStrategy.Eager
})
export class DfFormfieldComponent implements OnInit, OnDestroy {
private injector = inject(Injector);
private cdr = inject(ChangeDetectorRef);
private componentService = inject(DfComponentLoaderService);
private parentFormGroup = inject(FormGroupDirective);
config = input.required<DfBaseConfig>();
private componentInstance: WritableSignal<DfBaseComponent<DfBaseConfig> | undefined> =
signal(undefined);
private hasHiddenChild = signal(false);
@HostBinding('class.has-hidden-child')
get hasHiddenChildClass() {
return this.hasHiddenChild();
}
validationConfigs = input<ValidationConfig[]>();
formAclPath = input<string>();
defaultSpacing = input<DfFormfieldSpacing>();
isRetailChannel = input.required<boolean>();
@ViewChild('container', { read: ViewContainerRef, static: true }) containerRef?: ViewContainerRef;
readonly formEvent = output<DfEventPayload>();
/**
* Additionally to the groupChanged 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.)
*
* This subject will fire with an undefined value, after the configuration was passed to the component,
* if the component doesn't have a form control.
*/
componentOrControlInitFinished = new ReplaySubject<void>(1);
/**
* Defines the spacing of the formfield.
*/
@HostBinding('class.df-spacing-xs') get isSpacingXS() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.xs);
}
@HostBinding('class.df-spacing-s') get isSpacingS() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.s);
}
@HostBinding('class.df-spacing-m') get isSpacingM() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.m);
}
@HostBinding('class.df-spacing-l') get isSpacingL() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.l);
}
@HostBinding('class.df-spacing-xl') get isSpacingXL() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.xl);
}
@HostBinding('class.df-spacing-xxl') get isSpacingXXL() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.xxl);
}
@HostBinding('class.df-spacing-none') get isSpacingNone() {
return this.checkFormfieldSpacing(DfFormfieldSpacing.none);
}
private tearDownComponentSubscriptions$ = new Subject<void>();
group: UntypedFormGroup = new UntypedFormGroup({});
private formControl?: AbstractControl;
component?: ComponentRef<DfBaseComponent>;
constructor() {
effect(() => {
const componentInstance = this.componentInstance();
if (componentInstance && this.validationConfigs()) {
this.component?.setInput('validationConfigs', this.validationConfigs());
}
});
effect(() => {
const componentInstance = this.componentInstance();
if (isHideable(componentInstance)) {
this.hasHiddenChild.set(componentInstance.isHidden());
}
});
}
ngOnInit() {
this.group = this.parentFormGroup.form;
// The ngOnInit call ignores the async, so be careful what you do here:
this.createAndInsertFormfieldComponent();
}
ngOnDestroy(): void {
// Store this.config() in a variable, because it might not be available in the setTimeout callback,
// Angular cleans up inputs shortly after ngOnDestroy completes.
const configValue = this.config();
// Delay the removal to prevent an ExpressionChangedAfterItHasBeenCheckedError:
setTimeout(() => {
this.containerRef?.clear();
if (
this.formControl &&
configValue &&
!(this.group as AclExtendedFormGroup).__aclSyncedViewControls?.has(configValue.id)
) {
// Reset the form, which also removes the data from this field from the state.
// ACL hidden controls are not removed from the form onDestroy, in order to be able to cache them.
this.formControl.reset();
}
// Remove old FormControl from group
// There is a catch here: Removing the control also cancels the reset event.
// TODO: Nice to have: Find a solution that ensures that the removal is only done
// after the reset is handled everywhere.
// The following ensures that only the control of this component is removed from the group.
// It is possible, that the control was replaced by another one with the same ID in the meantime.
// This syntax "this.group.get([configValue.id])" is needed instead of this one "this.group.get(configValue.id)" because
// otherwise the "get" function will use "." in the ids as a key separator
if (configValue && this.group && this.group.get([configValue.id]) === this.formControl) {
this.group.removeControl(configValue.id);
}
this.tearDownComponentSubscriptions$.next();
});
}
private checkFormfieldSpacing(spacing: DfFormfieldSpacing): boolean {
const configValue = this.config();
if (configValue?.spacing) {
return configValue.spacing === spacing;
}
return this.defaultSpacing() === spacing;
}
private safelyGetFormGroup(): UntypedFormGroup {
if (!this.group) {
this.group = new UntypedFormGroup({});
}
return this.group;
}
private configureComponent() {
// Stop listening to any previously configured component
this.tearDownComponentSubscriptions$.next();
const componentInstance = this.component?.instance;
this.componentInstance.set(componentInstance);
const configValue = this.config();
if (componentInstance && configValue) {
// Pass down the props
this.component?.setInput('config', configValue);
this.component?.setInput('isRetailChannel', this.isRetailChannel());
this.component?.setInput('formAclPath', this.formAclPath());
// If the formGroup has an ACL cached control for this field, use it.
const group = this.safelyGetFormGroup();
if (
group.get([configValue.id]) &&
(group as AclExtendedFormGroup).__aclSyncedViewControls?.has(configValue.id)
) {
this.component?.setInput('control', group.get([configValue.id]));
this.component?.setInput('isAclHandled', true);
}
// Subscribe to changes to component's AbstractControl
componentInstance.componentOrControlInitFinished
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe((control) => {
// Using a setTimeout here prevents an ExpressionChangedAfterItHasBeenCheckedError error.
setTimeout(() => {
this.onFormControlChanged(control);
this.cdr.markForCheck();
}, 0);
});
outputToObservable(componentInstance.formEvent)
.pipe(takeUntil(this.tearDownComponentSubscriptions$))
.subscribe((event) => {
this.formEvent.emit(event);
this.cdr.markForCheck();
});
}
}
private onFormControlChanged = (formControl: AbstractControl | undefined) => {
const configValue = this.config();
if (configValue?.type === 'LINE_BREAK') {
this.componentOrControlInitFinished.next();
return;
}
if (!configValue?.id) {
return;
}
this.formControl = formControl;
const group = this.safelyGetFormGroup();
group.setControl(configValue.id, formControl);
this.componentOrControlInitFinished.next();
};
private async createAndInsertFormfieldComponent() {
const configValue = this.config();
if (!configValue?.type) {
return;
}
// Create desired form field component
const { component, moduleRef } = await this.componentService.getComponent(configValue);
this.component = this.containerRef?.createComponent<DfBaseComponent>(
component as unknown as typeof DfBaseComponent,
{
ngModuleRef: moduleRef,
injector: this.injector
}
);
this.configureComponent();
}
}
<ng-template #container></ng-template>
@use '../breakpoints.scss' as *;
:host {
--df-formfield-spacing-desktop-default: var(--vertical-inner-section-spacing);
--df-formfield-spacing-mobile-default: var(--vertical-inner-section-spacing);
&:has(df-headline) {
--df-formfield-spacing-desktop-default: 0px !important;
--df-formfield-spacing-mobile-default: 0px !important;
}
display: block;
::ng-deep {
// Removes NDBX formfield paddings
--formfield-bottom-padding: 0;
--formfield-mobile-bottom-padding: 0;
// ng-aquila's compatibility.css applies `margin: 12px 0` to
// .nx-error--message globally. Inside DF fields the spacing is owned by the
// formfield resp. the Aquila component itself, so only the compatibility
// layer's margin must be neutralized here.
//
// The target is wrapped in :where() so this rule keeps the specificity of
// the :host attribute alone (0,1,0) — exactly matching compatibility.css.
// It therefore beats compatibility.css purely by source order (component
// styles are injected after the global stylesheet), while the components'
// own, higher-specificity (0,2,0) margins keep applying untouched.
:where(.nx-error--message) {
margin: 0;
}
}
&:not(:empty) {
/**
* Formfield components can override the default spacing by overriding the following tokens:
* --df-formfield-spacing-desktop-default
* --df-formfield-spacing-mobile-default
*/
margin-bottom: var(--df-formfield-spacing-desktop-default);
&.retail {
margin-bottom: var(--df-formfield-spacing-desktop-default);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-default);
}
}
&.df-spacing-none {
margin-bottom: var(--df-formfield-spacing-none, 0);
}
&.df-spacing-xs {
margin-bottom: var(--df-formfield-spacing-desktop-xs, 8px);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-xs, 8px);
}
}
&.df-spacing-s {
margin-bottom: var(--df-formfield-spacing-desktop-s, 16px);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-s, 8px);
}
}
&.df-spacing-m {
margin-bottom: var(--df-formfield-spacing-desktop-m, 24px);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-m, 16px);
}
}
&.df-spacing-l {
margin-bottom: var(--df-formfield-spacing-desktop-l, 32px);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-l, 24px);
}
}
&.df-spacing-xl {
margin-bottom: var(--df-formfield-spacing-desktop-xl, 40px);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-xl, 32px);
}
}
&.df-spacing-xxl {
margin-bottom: var(--df-formfield-spacing-desktop-xxl, 48px);
@container (max-width: #{$container-breakpoint-m}) {
margin-bottom: var(--df-formfield-spacing-mobile-xxl, 40px);
}
}
}
&:empty,
&.has-hidden-child {
display: none;
}
}
// Removes last visible formfield's margin-bottom.
::ng-deep df-form {
// Remove margin from any visible formfield that has no visible siblings after it
df-formfield:not(:empty):not(.has-hidden-child):not(:has(~ df-formfield:not(.has-hidden-child))) {
margin-bottom: 0 !important;
}
df-formfield:last-of-type {
margin-bottom: 0 !important;
}
}
Legend
Html element with directive