libs/core-forms/src/lib/summary-component-plugin/summary.component.ts

Extends

DfCustomComponent<SummaryConfig>

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Inputs
Outputs

Inputs

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 DfBaseConfig config interface as needed and expose that their own config interface.

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

Outputs

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.

Properties

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>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""