libs/core-forms/src/lib/journey-preview-plugin/journey-preview-plugin.ts
Properties |
|
| __talyApplySpacingTokens (Optional) | |
| Type |
function
|
⚠️ 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.
@allianz/taly-core-forms package to the libraries array of the pages.jsonc file"libraries": [
{
"package": "@allianz/taly-core-forms",
"version": "CURRENT_VERSION"
}
]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."plugins": [
{
"package": "@allianz/taly-core-forms",
"modules": [
{
"name": "JourneyPreviewPluginModule",
"options": {
"enabled": true
}
}
]
}
],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).
| Function | Registered by | Purpose |
|---|---|---|
window.__talyApplySpacingTokens(target) | JourneyPreviewPlugin | Copies the relevant spacing CSS properties from df-form onto the target element. |
window.__talyApplyShadowStyles(shadowRoot) | WebComponentOverlayContainer | Copies 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. |
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);
}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);
});
}
}
}
}