File

libs/core-forms/src/lib/journey-preview-plugin/journey-preview-plugin.ts

Index

Properties

Properties

__talyApplySpacingTokens (Optional)
Type function


type: plugin title: journey-preview-plugin description: This plugin provides a journey preview displaying all dynamic forms in the same page runtimeCompatible: true

⚠️ This plugin is for internal usage only!

This plugin provides a journey preview displaying all dynamic form building blocks in the same page. Each page will be rendered as a section in the journey preview page.

How to use the CoreForms journey preview plugin?

  1. Add the @allianz/taly-core-forms package to the libraries array of the pages.jsonc file
Example :
"libraries": [
  {
    "package": "@allianz/taly-core-forms",
    "version": "CURRENT_VERSION"
  }
]
  1. Add the JourneyPreviewPluginModule with the options below to the plugins array of the pages.jsonc file. If you want to disable the plugin, set the enabled option to false or just remove the options object.
Example :
"plugins": [
  {
    "package": "@allianz/taly-core-forms",
    "modules": [
      {
        "name": "JourneyPreviewPluginModule",
        "options": {
          "enabled": true
        }
      }
    ]
  }
],

Extracting a field from the preview (DevTools)

When the preview plugin is enabled, two global helper functions are exposed on window so you can render any field in isolation: __talyApplySpacingTokens (provided by the plugin) and __talyApplyShadowStyles (registered when the app uses shadow DOM).

Window bridge functions

FunctionRegistered byPurpose
window.__talyApplySpacingTokens(target)JourneyPreviewPluginCopies the relevant spacing CSS properties from df-form onto the target element.
window.__talyApplyShadowStyles(shadowRoot)WebComponentOverlayContainerCopies all Angular component styles and the global styles.css into the target shadow root. For A1 journeys, it also keeps the A1 theme attributes of the shadow root host in sync with the current theme.

How to extract a field

Option A — Isolate into a new container

Creates a fresh shadow root on <body> and moves the field there. Useful for rendering a field completely standalone.

Paste the following into the Chrome DevTools console with the preview plugin enabled:

Example :
// Get the app's shadow root
const appRoot = document.querySelector('om-coreform');
const appShadow = appRoot.shadowRoot;

// Create an isolated container on <body> with its own shadow root
const container = document.createElement('div');
document.body.appendChild(container);
const newShadow = container.attachShadow({ mode: 'open' });

// Hydrate the new shadow root with Angular styles and spacing tokens
window.__talyApplyShadowStyles(newShadow); // shadow DOM apps only
window.__talyApplySpacingTokens(newShadow);

// Move the target field into the new shadow root (replace the data-df-id value)
const dfFormField = appShadow.querySelector('[data-df-id="myRadioButtons"]');
if (dfFormField) {
  newShadow.appendChild(dfFormField);
}

Option B — Move into an existing shadow root

Skip the container/shadow-root creation and reference your existing shadow root directly.

Example :
// Get the app's shadow root
const appRoot = document.querySelector('om-coreform');
const appShadow = appRoot.shadowRoot;

// Reference your existing shadow root (replace 'my-component' with your selector)
const existingShadow = document.querySelector('my-component').shadowRoot;

// Hydrate the existing shadow root with Angular styles and spacing tokens
window.__talyApplyShadowStyles(existingShadow); // shadow DOM apps only
window.__talyApplySpacingTokens(existingShadow);

// Move the target field into the existing shadow root (replace the data-df-id value)
const dfFormField = appShadow.querySelector('[data-df-id="myRadioButtons"]');
if (dfFormField) {
  existingShadow.appendChild(dfFormField);
}

import {
  DynamicFormBBConfiguration,
  isDynamicFormBbConfig,
  PageConfiguration
} from '@allianz/taly-core/schemas';
import { PfeRuntimeConfigService } from '@allianz/taly-pfe-connector';
import { isPlatformBrowser } from '@angular/common';
import { effect, inject, Injectable, OnDestroy, PLATFORM_ID, Signal } from '@angular/core';
import {
  CORE_FORMS_JOURNEY_PREVIEW_PLUGIN_OPTIONS,
  CoreFormsJourneyPreviewPluginOptions
} from './journey-preview-plugin.module';
import { PfeNavigationUtilService } from '@allianz/ngx-pfe';
import { A1_COLOR_SCHEME, APP_ROOT_ELEMENT_REF } from '@allianz/taly-core';

declare global {
  interface Window {
    __talyApplySpacingTokens?: (targetContainer: HTMLElement | ShadowRoot) => void;
  }
}

interface PreviewPageConfig {
  id: string;
  blocks: DynamicFormBBConfiguration[];
  pageData: PageConfiguration['pageData'];
}

@Injectable()
export class JourneyPreviewPlugin implements OnDestroy {
  private static readonly SPACING_TOKENS = [
    '--vertical-inner-section-spacing',
    '--vertical-helper-high-spacing',
    '--vertical-helper-low-spacing',
    '--vertical-helper-headline-spacing',
    '--vertical-headline-helper',
    '--vertical-section-inner'
  ] as const;

  private appRootElementRef = inject(APP_ROOT_ELEMENT_REF);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private readonly a1ColorScheme = inject(A1_COLOR_SCHEME);
  private readonly runtimeConfigService = inject(PfeRuntimeConfigService);
  private readonly pfeNavigationUtilService = inject(PfeNavigationUtilService);
  private options = inject<Signal<CoreFormsJourneyPreviewPluginOptions>>(
    CORE_FORMS_JOURNEY_PREVIEW_PLUGIN_OPTIONS
  );
  // Property used to prevent an infinite loop when reassigning the runtimeConfigService.pagesConfig
  private updatedPagesConfig: PageConfiguration[] = [];
  private spacingObserver: ResizeObserver | undefined;
  private registeredSpacingTargets = new Set<HTMLElement>();

  constructor() {
    effect(() => {
      const css = this.previewWrapperCss();
      const style = this.appRootElementRef
        .shadowRoot?.()
        ?.querySelector('style[preview-wrapper-css="true"]');
      if (style) {
        style.textContent = css;
      }
    });

    effect(() => {
      if (!this.isBrowser) {
        return;
      }
      const options = this.options();
      const pages = this.runtimeConfigService.pagesConfig().pages;
      if (!options?.enabled) {
        this.removePreviewChanges();
        this.spacingObserver?.disconnect();
        this.spacingObserver = undefined;
        this.registeredSpacingTargets.clear();
        delete (window as Window).__talyApplySpacingTokens;
        return;
      }
      this.registerSpacingTokenBridge();
      // Check that prevents an infinite loop when the runtimeConfigService.pagesConfig is reassigned
      if (options?.enabled && this.updatedPagesConfig !== pages) {
        const previewPageConfigs = this.getPreviewPageConfigs(pages);
        this.pfeNavigationUtilService.getFirstPage().then((firstPageId) => {
          this.updateConfig(previewPageConfigs, firstPageId);
          // Timeout to ensure that the styles are applied after the DOM has been updated
          setTimeout(() => {
            this.removePreviewChanges();
            this.applyPreviewChanges(previewPageConfigs);
          }, 100);
        });
      }
    });
  }

  ngOnDestroy(): void {
    if (!this.isBrowser) {
      return;
    }
    this.spacingObserver?.disconnect();
    this.registeredSpacingTargets.clear();
    delete (window as Window).__talyApplySpacingTokens;
  }

  private getPreviewPageConfigs(pages: PageConfiguration[]): PreviewPageConfig[] {
    return pages.map((page) => ({
      id: page.id,
      blocks: (page.blocks ?? []).filter(isDynamicFormBbConfig),
      pageData: page.pageData
    }));
  }

  private registerSpacingTokenBridge(): void {
    if (window.__talyApplySpacingTokens) {
      return;
    }

    const applyToTarget = (target: HTMLElement, dfForm: HTMLElement | null): void => {
      if (!dfForm) return;

      const computedStyle = getComputedStyle(dfForm);
      for (const token of JourneyPreviewPlugin.SPACING_TOKENS) {
        target.style.setProperty(token, computedStyle.getPropertyValue(token));
      }
    };

    const applyToAll = (dfForm: HTMLElement | null) => {
      this.registeredSpacingTargets.forEach((target) => applyToTarget(target, dfForm));
    };

    (window as Window).__talyApplySpacingTokens = (
      targetContainer: HTMLElement | ShadowRoot
    ): void => {
      const shadowRoot = this.appRootElementRef.shadowRoot?.();
      if (!shadowRoot) return;

      const dfForm = shadowRoot.querySelector('df-form') as HTMLElement | null;
      if (!dfForm) return;

      const target =
        targetContainer instanceof ShadowRoot
          ? (targetContainer.host as HTMLElement)
          : targetContainer;

      this.registeredSpacingTargets.add(target);

      if (!this.spacingObserver) {
        this.spacingObserver = new ResizeObserver(() => applyToAll(dfForm));
        this.spacingObserver.observe(dfForm);
      }

      // Apply immediately to the newly registered target
      applyToTarget(target, dfForm);
    };
  }

  // Move dynamic form BBs from all pages into the first page
  private updateConfig(previewPageConfigs: PreviewPageConfig[], firstPageId: string): void {
    const pagesConfig = structuredClone(this.runtimeConfigService.pagesConfig().pages);
    const firstPage = pagesConfig.find((page) => page.id === firstPageId);
    if (!firstPage) {
      return;
    }

    // Strip inline ACL rules so all fields are visible regardless of access control in preview mode
    firstPage.blocks = previewPageConfigs
      .flatMap((config) => config.blocks)
      .map((block) => ({
        ...block,
        form: {
          ...block.form,
          acl: undefined,
          fields: block.form.fields.map((field) => {
            const fieldCopy = { ...field };
            delete fieldCopy.acl;
            return fieldCopy;
          })
        }
      }));

    firstPage.pageData = {
      pageActionConfig: {
        backButtonHidden: true,
        nextButtonHidden: true
      }
    };

    // Remove all other properties in the first page
    Object.keys(firstPage).forEach((key) => {
      if (!['id', 'blocks', 'pageData'].includes(key)) {
        delete firstPage[key as keyof PageConfiguration];
      }
    });

    // The pages configuration is reassigned with the new config
    this.updatedPagesConfig = pagesConfig;
    this.runtimeConfigService.pagesConfig.set({
      ...this.runtimeConfigService.pagesConfig(),
      pages: pagesConfig
    });
  }

  // Add a wrapper and apply styles to it to visually separate pages in preview mode
  private applyPreviewChanges(previewPageConfigs: PreviewPageConfig[]): void {
    if (this.appRootElementRef.shadowRoot) {
      const shadowRoot = this.appRootElementRef.shadowRoot();
      if (shadowRoot) {
        // Append styles
        const style = document.createElement('style');
        style.setAttribute('preview-wrapper-css', 'true');
        style.textContent = this.previewWrapperCss();
        shadowRoot.appendChild(style);

        // Array of dynamic form BBs elements displayed in the DOM
        const elementsInDOM = Array.from(
          shadowRoot.querySelectorAll('[data-section-name="section"]')
        ) as HTMLElement[];
        let elementsInDOMIndex = 0;

        previewPageConfigs.forEach((pageConfig) => {
          // Number of dynamic form BBs configured in this page
          const dynamicFormBbsInPage = pageConfig.blocks.length;
          const elementsInDOMPerPage = elementsInDOM.slice(
            elementsInDOMIndex,
            elementsInDOMIndex + dynamicFormBbsInPage
          );
          elementsInDOMIndex += dynamicFormBbsInPage;

          if (elementsInDOMPerPage.length > 0) {
            // Div wrapper for the dynamic form BBs in this page
            const wrapper = document.createElement('div');
            wrapper.setAttribute('preview-wrapper', 'true');
            wrapper.className = 'journey-preview-wrapper';
            const label = document.createElement('span');
            label.className = 'journey-preview-wrapper__label';
            label.textContent = 'Page id: ' + pageConfig.id;
            wrapper.appendChild(label);

            // Insert wrapper before the first element and append the dynamic form BBs to it
            elementsInDOMPerPage[0].parentNode?.insertBefore(wrapper, elementsInDOMPerPage[0]);
            elementsInDOMPerPage.forEach((el) => wrapper.appendChild(el));

            // Append visual-only action buttons based on this page's config
            const pageActionConfig = pageConfig.pageData?.pageActionConfig;
            const actionsContainer = document.createElement('div');
            actionsContainer.className = 'journey-preview-actions';
            actionsContainer.setAttribute('preview-actions', 'true');

            if (!pageActionConfig?.backButtonHidden) {
              const backBtn = document.createElement('button');
              backBtn.className = 'journey-preview-actions__button--back';
              const backLabel =
                typeof pageActionConfig?.backButtonLabel === 'string'
                  ? pageActionConfig.backButtonLabel
                  : 'Back';
              const backIcon = document.createElement('span');
              backIcon.className = 'ndbx-icon nx-icon--arrow-left';
              const backLabelSpan = document.createElement('span');
              backLabelSpan.className = 'journey-preview-actions__button--back-label';
              backLabelSpan.textContent = backLabel;
              backBtn.appendChild(backIcon);
              backBtn.appendChild(backLabelSpan);
              actionsContainer.appendChild(backBtn);
            }

            if (!pageActionConfig?.nextButtonHidden) {
              const nextBtn = document.createElement('button');
              nextBtn.className = 'journey-preview-actions__button--next';
              nextBtn.textContent =
                typeof pageActionConfig?.nextButtonLabel === 'string'
                  ? pageActionConfig.nextButtonLabel
                  : 'Next';
              actionsContainer.appendChild(nextBtn);
            }

            if (actionsContainer.children.length > 0) {
              wrapper.appendChild(actionsContainer);
            }
          }
        });
      }
    }
  }

  private previewWrapperCss(): string {
    return `
      .journey-preview-wrapper {
        border: 2px dashed #C7C7C6;
        padding: 8px;
        margin-bottom: 16px;
        background-color: ${this.a1ColorScheme() === 'dark' ? '#0F1F3A' : '#F2F2FF'};
        container-type: inline-size;
        container-name: preview-wrapper;
      }
      .journey-preview-wrapper__label {
        font-weight: bold;
        color: #C7C7C6;
        font-size: 20px;
      }
      .journey-preview-actions {
        display: flex;
        align-items: center;
        gap: 16px;
        margin-top: 16px;
      }
      .journey-preview-actions__button--next {
        width: auto;
        min-width: 256px;
        padding: var(--button-medium-padding, 12px 16px);
        border-width: var(--button-border-width, 2px);
        border-style: solid;
        border-color: var(--button-primary-border-color, #007AB3);
        border-radius: var(--button-border-radius, 0px);
        background-color: var(--button-primary-background-color, #007AB3);
        color: var(--button-primary-text-color, #fff);
        font-size: var(--button-medium-font-size, 1rem);
        line-height: var(--button-medium-line-height, 1.5rem);
        font-weight: var(--button-medium-font-weight, 600);
        letter-spacing: var(--button-medium-letter-spacing, 0.0625rem);
        min-height: var(--button-medium-height, 48px);
        text-transform: var(--button-text-transform, none);
        cursor: default;
      }
      .journey-preview-actions__button--back {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
        width: auto;
        min-width: 256px;
        padding: 0;
        border: none;
        background: transparent;
        color: var(--plain-button-color, #007AB3);
        font-size: var(--plain-button-font-size, 0.875rem);
        line-height: var(--plain-button-line-height, 1.25rem);
        font-weight: var(--plain-button-font-weight, 600);
        letter-spacing: var(--plain-button-letter-spacing, 0.0625rem);
        cursor: default;
      }
      .journey-preview-actions__button--back-label {
        text-decoration: var(--plain-button-text-decoration, underline);
      }
      @container preview-wrapper (max-width: 703px) {
        .journey-preview-actions {
          flex-direction: column;
          align-items: stretch;
        }
        .journey-preview-actions__button--next {
          display: block;
          width: 100%;
        }
      }
    `;
  }

  // Remove previously applied wrappers and styles
  private removePreviewChanges(): void {
    if (this.appRootElementRef.shadowRoot) {
      const shadowRoot = this.appRootElementRef.shadowRoot();
      if (shadowRoot) {
        // Remove style element
        const styleEl = shadowRoot.querySelector('style[preview-wrapper-css="true"]');
        if (styleEl) {
          shadowRoot.removeChild(styleEl);
        }
        const oldWrappers = shadowRoot.querySelectorAll('[preview-wrapper="true"]');
        oldWrappers.forEach((wrapper) => {
          // Remove page id label
          wrapper.removeChild(wrapper.childNodes[0]);
          // Remove action buttons container if present
          const actionsEl = wrapper.querySelector('[preview-actions="true"]');
          if (actionsEl) {
            wrapper.removeChild(actionsEl);
          }
          // Move remaining children out of the wrapper
          while (wrapper.firstChild) {
            wrapper.parentNode?.insertBefore(wrapper.firstChild, wrapper);
          }
          wrapper.parentNode?.removeChild(wrapper);
        });
      }
    }
  }
}

results matching ""

    No results matching ""