libs/core-forms/src/lib/summary-component-plugin/summary.component.ts
DfCustomComponent<SummaryConfig>
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | df-summary |
| standalone | true |
| imports |
NxColComponent
NxDataDisplayComponent
NxDataDisplayLabelComponent
BooleanPipe
NxHeadlineComponent
|
| templateUrl | ./summary.component.html |
No results matching.
DfBaseModule
NxGridModule
NxColComponent
NxDataDisplayComponent
NxDataDisplayLabelComponent
BooleanPipe
DatePipe
NxHeadlineComponent
DfCustomComponent<SummaryConfig>
Properties |
|
Inputs |
Outputs |
| control | |
Type : undefined
|
|
Default value : undefined
|
|
|
Inherited from
DfBaseComponent
|
|
| config | |
Type : C
|
|
| Required : true | |
|
Inherited from
DfBaseComponent
|
|
|
The configuration object for this formfield. Note that derived formfield components should extend the |
|
| formAclPath | |
Type : string
|
|
|
Inherited from
DfBaseComponent
|
|
| isAclHandled | |
Type : boolean
|
|
Default value : false
|
|
|
Inherited from
DfBaseComponent
|
|
| isRetailChannel | |
Type : boolean
|
|
|
Inherited from
DfBaseComponent
|
|
| validationConfigs | |
Type : ValidationConfig[] | undefined
|
|
|
Inherited from
DfBaseComponent
|
|
| formEvent | |
Type : DfEventPayload
|
|
|
Inherited from
DfBaseComponent
|
|
|
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 DEFAULT_COL_WIDTH |
Type : string
|
Default value : '12,6,6,4'
|
| componentOrControlInitFinished |
Type : unknown
|
Default value : new ReplaySubject<AbstractControl | undefined>(1)
|
|
Inherited from
DfBaseComponent
|
|
This ReplaySubject is provided to emit the control once it is initialized. |
| errorMessages |
Type : WritableSignal<Observable[]>
|
Default value : signal([])
|
|
Inherited from
DfBaseComponent
|
|
The resolved validation error messages for the current control state. |
| Readonly nxErrorAppearance |
Type : ErrorStyleType
|
Default value : inject(ERROR_DEFAULT_OPTIONS, { optional: true })?.appearance ||
(inject<CHANNEL>(CHANNEL_TOKEN) === CHANNEL.EXPERT ? 'text' : 'message')
|
|
Inherited from
DfBaseComponent
|
import { DfBaseModule, DfCustomComponent } from '@allianz/taly-core/dynamic-form';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { NxColComponent, NxGridModule } from '@allianz/ng-aquila/grid';
import {
NxDataDisplayComponent,
NxDataDisplayLabelComponent
} from '@allianz/ng-aquila/data-display';
import { toObservable } from '@angular/core/rxjs-interop';
import { map, switchMap } from 'rxjs/operators';
import { combineLatest } from 'rxjs';
import { PfeRuntimeConfigService } from '@allianz/taly-pfe-connector';
import { isDynamicFormBbConfig } from '@allianz/taly-core/schemas';
import { PfeBusinessService } from '@allianz/ngx-pfe';
import { BooleanPipe } from './summary-component.boolean.pipe';
import { DatePipe } from './summary-component.date.pipe';
import { LOCALE_RUNTIME_TOKEN } from '@allianz/taly-core';
import { DfRadioConfig } from '@allianz/taly-core/dynamic-form/radio';
import { DfCircleToggleGroupConfig } from '@allianz/taly-core/dynamic-form/circle-toggle-group';
import { DfDropdownConfig } from '@allianz/taly-core/dynamic-form/dropdown';
import { AsYouType } from 'libphonenumber-js/max';
import { DfCheckboxGroupConfig } from '@allianz/taly-core/dynamic-form/checkbox';
import { DfTileConfig } from '@allianz/taly-core/dynamic-form/tile';
import { NxHeadlineComponent } from '@allianz/ng-aquila/headline';
import { DfNumberStepperConfig } from '@allianz/taly-core/dynamic-form/number-stepper';
import { DfTimeConfig } from '@allianz/taly-core/dynamic-form/time';
export interface SummaryConfig {
/**
* Dictionary for field translations.
*/
translations: SummaryConfigTranslations;
/**
* Optional allowlist, only fields specified here will be included in the summary.
* If not provided or empty, all fields will be included.
*
* @example
* ```json
* [
* "firstName",
* "lastName",
* "email"
* ]
* ```
*/
fields?: Array<string>;
}
export interface SummaryConfigTranslations {
/**
* Translation for boolean true.
*
* @example
* Yes
*/
booleanTrue: string;
/**
* Translation for boolean false.
* @example
* No
*/
booleanFalse: string;
}
const EXCLUDED_CUSTOM_COMPONENTS = ['TurnstileComponent', 'HiddenFieldComponent'];
interface SummaryFieldConfig {
id: string;
type: string;
label?: string;
name?: string;
}
interface SummaryField {
fieldKey: string;
fieldLabel: string;
fieldValue: string;
fieldType: string | undefined;
}
interface SummarySegment {
pageLabel: string;
fields: SummaryField[];
}
interface SummaryViewModel {
showHeadings: boolean;
segments: SummarySegment[];
}
@Component({
selector: 'df-summary',
templateUrl: './summary.component.html',
imports: [
DfBaseModule,
NxGridModule,
NxColComponent,
NxDataDisplayComponent,
NxDataDisplayLabelComponent,
BooleanPipe,
DatePipe,
NxHeadlineComponent
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummaryComponent extends DfCustomComponent<SummaryConfig> {
readonly DEFAULT_COL_WIDTH = '12,6,6,4';
// Override the control input to remove this from form control
override control = input<undefined>(undefined);
protected allowedFields = computed(() => this.config().config?.fields || []);
protected translations = computed(
() =>
this.config().config?.translations || {
booleanTrue: 'Yes',
booleanFalse: 'No'
}
);
protected runtimeLocale = inject(LOCALE_RUNTIME_TOKEN);
private readonly pfeBusinessService = inject(PfeBusinessService);
private readonly runtimeConfigService = inject(PfeRuntimeConfigService);
private pagesWithDynamicFormBlocks = computed(() => {
const { pages } = this.runtimeConfigService.pagesConfig();
return pages.map((page) => ({
pageId: page.id,
blocks: (page?.blocks ?? []).filter(isDynamicFormBbConfig)
}));
});
/**
* Lookup from `pageId` to the label of the section that contains it. If a page is not part
* of any configured section, there is no entry — consumers should fall back to the `pageId`.
*/
private pageIdToSectionLabel = computed(() => {
const sections = this.runtimeConfigService.pagesConfig().frame?.navigation?.sections ?? [];
const mapping = new Map<string, string>();
for (const section of sections) {
for (const pageId of section.pages ?? []) {
mapping.set(pageId, section.label);
}
}
return mapping;
});
private dynamicFormBlocks = computed(() =>
this.pagesWithDynamicFormBlocks().flatMap((page) => page.blocks)
);
private formIds$ = toObservable(this.dynamicFormBlocks).pipe(
map((blocks) => blocks.map((block) => block.id))
);
private readonly pfeState$ = this.formIds$.pipe(
switchMap((formConfigs) =>
combineLatest(
formConfigs.map((formId) => this.pfeBusinessService.getObservableForKey(formId, true))
)
),
// Flatten form values
map((formValues: unknown[]) => {
const merged: Record<string, unknown> = {};
formValues.forEach((formValue) => {
Object.entries(formValue || {}).forEach(([key, value]) => {
if (Object.prototype.hasOwnProperty.call(merged, key)) {
console.warn(
`The form field with id "${key}" is present in more than one form. ` +
`Please provide a unique id for each field.`
);
}
merged[key] = value;
});
});
return merged;
})
);
/**
* Observable that produces the final, template-ready summary view model.
* Updates automatically whenever:
* - The form values change
* - The form configuration changes (field labels, etc.)
* - The allowlist changes
*
* When an allowlist is configured, its order drives the output order — which means the
* same pageId may appear in multiple, non-adjacent segments (e.g. `[p1, p2, p1]`).
* Adjacent fields belonging to the same page are merged into a single segment.
*
* Page headings are only rendered when the resulting summary spans more than one unique
* page; otherwise the form is considered effectively single-page.
*/
protected filteredSummary$ = combineLatest([
this.pfeState$,
toObservable(this.pagesWithDynamicFormBlocks),
toObservable(this.allowedFields)
]).pipe(
map(([formValues, pages, allowedFields]) =>
this.buildSummaryViewModel(formValues, pages, allowedFields)
)
);
private buildSummaryViewModel(
formValues: Record<string, unknown>,
pages: Array<{ pageId: string; blocks: Array<{ form?: { fields?: SummaryFieldConfig[] } }> }>,
allowedFields: string[]
): SummaryViewModel {
const includedFields = pages.flatMap((page) =>
page.blocks
.flatMap((block) => block.form?.fields ?? [])
.filter((fieldConfig) => this.shouldIncludeField(fieldConfig, formValues, allowedFields))
.map((fieldConfig) => ({
pageId: page.pageId,
fieldConfig,
fieldValue: formValues[fieldConfig.id]
}))
);
// When an allowlist is configured it drives the overall order; otherwise we keep
// the natural order (pages in config order, fields in page order).
if (allowedFields.length > 0) {
includedFields.sort(
(a, b) => allowedFields.indexOf(a.fieldConfig.id) - allowedFields.indexOf(b.fieldConfig.id)
);
}
const sectionLabels = this.pageIdToSectionLabel();
const fieldsWithPageLabel = includedFields.map((field) => ({
...field,
pageLabel: sectionLabels.get(field.pageId) ?? field.pageId
}));
const segments: SummarySegment[] = this.groupConsecutivelyByLabel(fieldsWithPageLabel).map(
(group) => ({
pageLabel: group.pageLabel,
fields: group.groupedFields.map((item) =>
this.formatField(item.fieldConfig, item.fieldValue)
)
})
);
const uniqueLabels = new Set(segments.map((segment) => segment.pageLabel));
return {
showHeadings: uniqueLabels.size > 1,
segments
};
}
private shouldIncludeField(
fieldConfig: SummaryFieldConfig,
formValues: Record<string, unknown>,
allowedFields: string[]
): boolean {
if (!Object.prototype.hasOwnProperty.call(formValues, fieldConfig.id)) {
return false;
}
if (
fieldConfig.type === 'CUSTOM_COMPONENT' &&
EXCLUDED_CUSTOM_COMPONENTS.includes(fieldConfig.name ?? '')
) {
return false;
}
return allowedFields.length === 0 || allowedFields.includes(fieldConfig.id);
}
private formatField(fieldConfig: SummaryFieldConfig, rawValue: unknown): SummaryField {
const fieldLabel = fieldConfig.label || fieldConfig.id;
// If a field is empty, its type is set to undefined to avoid formatting issues
const fieldType = rawValue !== null ? fieldConfig.type : undefined;
let fieldValue: unknown = rawValue;
// For circle toggles, radio and dropdowns fields: display the label instead of the value
if (fieldType === 'CIRCLE_TOGGLE_GROUP') {
fieldValue =
(fieldConfig as DfCircleToggleGroupConfig)?.options.find(
(option) => option.value === fieldValue
)?.label || fieldValue;
}
if (
fieldType === 'RADIO' ||
fieldType === 'TOGGLE_BUTTON' ||
(fieldType === 'DROPDOWN' && !(fieldConfig as DfDropdownConfig).multiSelect)
) {
const radioOptions = (fieldConfig as DfRadioConfig | DfDropdownConfig)?.options;
if (Array.isArray(radioOptions)) {
fieldValue =
radioOptions.find((option) => option.value === fieldValue)?.label || fieldValue;
}
}
if (fieldType === 'CHECKBOX_GROUP') {
const options = (fieldConfig as DfCheckboxGroupConfig)?.options;
if (Array.isArray(options) && Array.isArray(fieldValue)) {
const labels = fieldValue.map(
(val) => options.find((option) => option.value === val)?.label ?? val
);
fieldValue = labels.join(', ');
}
}
if (fieldType === 'TILE') {
const tileConfig = fieldConfig as DfTileConfig;
const options = tileConfig?.options;
if (tileConfig.multiSelect && Array.isArray(fieldValue)) {
const labels = fieldValue.map(
(val) => options?.find((option) => option.value === val)?.label ?? val
);
fieldValue = labels.join(', ');
} else {
fieldValue = options.find((option) => option.value === fieldValue)?.label || fieldValue;
}
}
if (fieldType === 'DROPDOWN' && (fieldConfig as DfDropdownConfig).multiSelect) {
const dropdownOptions = (fieldConfig as DfDropdownConfig)?.options;
if (Array.isArray(dropdownOptions) && Array.isArray(fieldValue)) {
fieldValue = fieldValue
.map((value) => {
return dropdownOptions.find((option) => option.value === value)?.label || value;
})
.join(', ');
}
}
// For phone input fields, format while typing
if (fieldType === 'PHONE_INPUT' && fieldValue) {
fieldValue = new AsYouType().input(fieldValue as string);
}
if (fieldType === 'NUMBER_STEPPER' && fieldValue !== null && fieldValue !== '') {
const { prefix, suffix } = fieldConfig as DfNumberStepperConfig;
fieldValue = `${prefix ? prefix + ' ' : ''}${fieldValue}${suffix ? ' ' + suffix : ''}`;
}
if (fieldType === 'TIME') {
const timeConfig = fieldConfig as DfTimeConfig;
if (typeof fieldValue === 'string' && fieldValue) {
const [hourStr, minuteStr] = fieldValue.split(':');
const hour = parseInt(hourStr, 10);
const minute = parseInt(minuteStr, 10);
const isValid = hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
if (!isValid) {
fieldValue = '';
} else if (timeConfig?.twelveHourFormat) {
const labelAM = timeConfig.labelAM || 'AM';
const labelPM = timeConfig.labelPM || 'PM';
const period = hour >= 12 ? labelPM : labelAM;
const hour12 = hour % 12 || 12;
const paddedHour = String(hour12).padStart(2, '0');
const paddedMinute = String(minute).padStart(2, '0');
fieldValue = `${paddedHour}:${paddedMinute} ${period}`;
} else {
const paddedHour = String(hour).padStart(2, '0');
const paddedMinute = String(minute).padStart(2, '0');
fieldValue = `${paddedHour}:${paddedMinute}`;
}
}
}
return {
fieldKey: fieldConfig.id,
fieldLabel,
fieldValue: fieldValue == null ? '' : String(fieldValue),
fieldType
};
}
/**
* Ensures that a value is either a number or a string.
* @param value - The value to check
* @returns The original value, if it's valid
*/
protected isStringOrNumber(value: unknown): number | string {
if (typeof value === 'number' || typeof value === 'string') {
return value as number | string;
}
throw Error(
`The value ${value} can not be converted to date as it is neither a string nor a number.`
);
}
/**
* Groups consecutive entries that share the same `pageLabel` into one segment.
* Non-adjacent occurrences of the same label produce separate segments — this is
* intentional when the summary's order is driven by the allowlist (`['f1P1', 'f2P2', 'f3P1']`
* must render as P1 → P2 → P1 rather than being merged across the P2 boundary).
*/
private groupConsecutivelyByLabel<T extends { pageLabel: string }>(
includedFields: T[]
): Array<{ pageLabel: string; groupedFields: T[] }> {
const groups: Array<{ pageLabel: string; groupedFields: T[] }> = [];
for (const includedField of includedFields) {
const lastGroup = groups[groups.length - 1];
if (lastGroup && lastGroup.pageLabel === includedField.pageLabel) {
lastGroup.groupedFields.push(includedField);
} else {
groups.push({ pageLabel: includedField.pageLabel, groupedFields: [includedField] });
}
}
return groups;
}
}
<div nxLayout="grid nopadding" [containerQuery]="true">
@if (filteredSummary$ | async; as summary) { @for (segment of summary.segments; track $index) {
@if (summary.showHeadings) {
<h3
nxHeadline="subsection-medium"
class="nx-margin-bottom-s"
[attr.data-testid]="'summary-heading-' + $index"
>
{{ segment.pageLabel }}
</h3>
}
<div nxRow>
@for (item of segment.fields; track item.fieldKey) {
<div
[nxCol]="DEFAULT_COL_WIDTH"
class="nx-margin-bottom-m"
[attr.data-testid]="'summary-field-' + item.fieldKey"
>
<nx-data-display size="small">
<nx-data-display-label>{{
item.fieldLabel | interpolateFromStore | async
}}</nx-data-display-label>
@switch (item.fieldType) { @case ('DATE') {
{{ isStringOrNumber(item.fieldValue) | date : runtimeLocale() }}
} @case ('CHECKBOX') {
{{ item.fieldValue | boolean : translations() }}
} @case ('SWITCHER') {
{{ item.fieldValue | boolean : translations() }}
} @default {
{{ item.fieldValue | interpolateFromStore | async }}
} }
</nx-data-display>
</div>
}
</div>
} }
</div>