libs/core-forms/src/lib/summary-component-plugin/summary.component.ts
Properties |
|
| translations | |
| Type |
SummaryConfigTranslations
|
|
Description
|
Dictionary for field translations. |
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;
}
}