File

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

Index

Properties

Properties

booleanFalse
Type string
Description

Translation for boolean false.

Example :
No
booleanTrue
Type string
Description

Translation for boolean true.

Example :
Yes
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;
  }
}

results matching ""

    No results matching ""