Skip to content

Commit

Permalink
feat(ICM 11): experimental Design View support for the PWA (within th…
Browse files Browse the repository at this point in the history
…e Intershop Administration Portal) (#1462)

* required functionality for the Intershop PWA to interact with the new IAP Design View
* wrapper for highlighting Pagelets, Slots and Includes
* enable Design View highlighting with 'DESIGNVIEW' PreviewContextId
* highlight only last element in hierarchy
* send post message event to Design View whenever the language has changed
* some basic documentation

Co-authored-by: Andreas Steinmann <asteinmann@intershop.com>
Co-authored-by: Silke <s.grueber@intershop.de>
Co-authored-by: Marcel Eisentraut <meisentraut@intershop.de>
Co-authored-by: Stefan Hauke <s.hauke@intershop.de>
  • Loading branch information
5 people committed Dec 28, 2023
1 parent e7346b5 commit 8452343
Show file tree
Hide file tree
Showing 20 changed files with 566 additions and 16 deletions.
13 changes: 12 additions & 1 deletion docs/concepts/cms-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,24 @@ CREATE src/app/cms/components/cms-inventory/cms-inventory.component.spec.ts (795
UPDATE src/app/cms/cms.module.ts (4956 bytes)
```

## Design View

> [!IMPORTANT]
> To use the new Design View for the PWA within the Intershop Administration Portal ICM version 11.7.0 or above is needed.
The Intershop PWA 5.0.0 introduces experimental support for the new Design View that can be used within the Intershop Administration Portal (IAP).
Besides access to the IAP ICM 11 is required, that provides the necessary CMS Management REST API to get information about available CMS models and the CMS page tree and to edit CMS components.

ICM 11 does not provide the basic support for a design preview (as mentioned in the next section) so the _Design View_ tab in the ICM backoffice cannot be used to preview content changes in the PWA.
The new Design View in the IAP currently only supports content editing but not content preview.

## Design Preview

In conjunction with Intershop Commerce Management (ICM) 7.10.39.1, Intershop PWA 3.3.0 introduced basic support for a design preview.
This means the _Design View_ tab in the ICM backoffice can be used to preview content changes in the PWA, but without any direct editing capabilities.
Direct item preview for products, categories and content pages works now as well in the context of the PWA.

The preview feature basically consists of the [`PreviewService`](../../src/app/core/services/preview/preview.service.ts) that handles the preview functionality by listening for `PreviewContextID` initialization or changes and saving it to the browser session storage.
The preview feature basically consists of the [`PreviewService`](../../src/app/core/utils/preview/preview.service.ts) that handles the preview functionality by listening for `PreviewContextID` initialization or changes and saving it to the browser session storage.
The [`PreviewInterceptor`](../../src/app/core/interceptors/preview.interceptor.ts) than handles adding a currently available PreviewContextID as matrix parameter `;prectx=` to all REST requests so they can be evaluated on the ICM side returning content fitting to the set preview context.

To end a preview session and to delete the saved `PreviewContextID` in the browser session storage, use the _Finish Preview_ button of the _Design View_ configuration.
Expand Down
4 changes: 2 additions & 2 deletions src/app/core/interceptors/preview.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/c
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

import { PreviewService } from 'ish-core/services/preview/preview.service';
import { PreviewService } from 'ish-core/utils/preview/preview.service';

/**
* add PreviewContextID to every request if it is available in the SessionStorage
Expand All @@ -12,7 +12,7 @@ export class PreviewInterceptor implements HttpInterceptor {
constructor(private previewService: PreviewService) {}

intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (this.previewService.previewContextId) {
if (this.previewService.previewContextId && !this.previewService.isDesignViewMode) {
return next.handle(
req.clone({
url: `${req.url};prectx=${this.previewService.previewContextId}`,
Expand Down
25 changes: 25 additions & 0 deletions src/app/core/utils/design-view/design-view.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';

import { getCurrentLocale } from 'ish-core/store/core/configuration';

import { DesignViewService } from './design-view.service';

describe('Design View Service', () => {
let designViewService: DesignViewService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideMockStore({
selectors: [{ selector: getCurrentLocale, value: 'en_US' }],
}),
],
});
designViewService = TestBed.inject(DesignViewService);
});

it('should be created', () => {
expect(designViewService).toBeTruthy();
});
});
151 changes: 151 additions & 0 deletions src/app/core/utils/design-view/design-view.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { ApplicationRef, Injectable } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Store, select } from '@ngrx/store';
import { filter, first, fromEvent, map, switchMap } from 'rxjs';

import { getCurrentLocale } from 'ish-core/store/core/configuration';
import { DomService } from 'ish-core/utils/dom/dom.service';
import { whenTruthy } from 'ish-core/utils/operators';

interface DesignViewMessage {
type:
| 'dv-clientAction'
| 'dv-clientNavigation'
| 'dv-clientReady'
| 'dv-clientRefresh'
| 'dv-clientLocale'
| 'dv-clientStable';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any;
}

@Injectable({ providedIn: 'root' })
export class DesignViewService {
private allowedHostMessageTypes = ['dv-clientRefresh'];

constructor(
private router: Router,
private appRef: ApplicationRef,
private domService: DomService,
private store: Store
) {
this.init();
}

/**
* Send a message to the host window.
* Send the message to any host since the PWA is not supposed to know a fixed IAP URL (we are not sending secrets).
*
* @param message The message to send to the host (including type and payload)
*/
messageToHost(message: DesignViewMessage) {
window.parent.postMessage(message, '*');
}

/**
* Start method that sets up Design View communication.
* Needs to be called *once* for the whole application.
*/
private init() {
if (!this.shouldInit()) {
return;
}

this.listenToHostMessages();
this.listenToApplication();

// tell the host client is ready
this.messageToHost({ type: 'dv-clientReady' });
}

/**
* Decides whether to init the Design View capabilities or not.
* Is used by the init method, so it will only initialize when
* (1) there is a window (i.e. the application does not run in SSR/Universal)
* (2) application does not run on top level window (i.e. it runs in the Design View iframe)
*/
private shouldInit() {
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
return typeof window !== 'undefined' && window.parent && window.parent !== window;
}

/**
* Subscribe to messages from the host window.
* Incoming messages are filtered using `allowedHostMessageTypes`
* Should only be called *once* during initialization.
*/
private listenToHostMessages() {
fromEvent<MessageEvent>(window, 'message')
.pipe(
filter(e => e.data.hasOwnProperty('type') && this.allowedHostMessageTypes.includes(e.data.type)),
map(message => message.data)
)
.subscribe(message => this.handleHostMessage(message));
}

/**
* Listen to events throughout the application and send message to host when
* (1) route has changed (`dv-clientNavigation`),
* (2) application is stable, i.e. all async tasks have been completed (`dv-clientStable`) or
* (3) content include has been reloaded (`dv-clientStable`).
*
* Should only be called *once* during initialization.
*/
private listenToApplication() {
const navigation$ = this.router.events.pipe(filter<NavigationEnd>(e => e instanceof NavigationEnd));
const stable$ = this.appRef.isStable.pipe(whenTruthy(), first());
const navigationStable$ = navigation$.pipe(switchMap(() => stable$));

// send `dv-clientNavigation` event for each route change
navigation$.subscribe(e => this.messageToHost({ type: 'dv-clientNavigation', payload: { url: e.url } }));

stable$.subscribe(() => {
this.applyHierarchyHighlighting();
});

// send `dv-clientStable` event when application is stable or loading of the content included finished
navigationStable$.subscribe(() => {
this.messageToHost({ type: 'dv-clientStable' });
this.applyHierarchyHighlighting();
});

// send `dv-clientLocale` event when application is stable and the current application locale was determined
stable$
.pipe(
switchMap(() =>
this.store.pipe(select(getCurrentLocale), whenTruthy()).pipe(
first() // PWA reloads after each locale change, only one locale is active during runtime
)
)
)
.subscribe(locale => this.messageToHost({ type: 'dv-clientLocale', payload: { locale } }));
}

/**
* Handle incoming message from the host window.
* Invoked by the event listener in `listenToHostMessages()` when a new message arrives.
*/
private handleHostMessage(message: DesignViewMessage) {
switch (message.type) {
case 'dv-clientRefresh': {
location.reload();
return;
}
}
}

/**
* Workaround for the missing Firefox CSS support for :has to highlight
* only the last .design-view-wrapper in the .design-view-wrapper hierarchy.
*
*/
private applyHierarchyHighlighting() {
const designViewWrapper: NodeListOf<HTMLElement> = document.querySelectorAll('.design-view-wrapper');

designViewWrapper.forEach(element => {
if (!element.querySelector('.design-view-wrapper')) {
this.domService.addClass(element, 'last-design-view-wrapper');
}
});
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable ish-custom-rules/no-intelligence-in-artifacts */
import { ApplicationRef, Injectable } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store, select } from '@ngrx/store';
Expand All @@ -8,7 +7,7 @@ import { getICMBaseURL } from 'ish-core/store/core/configuration';
import { whenTruthy } from 'ish-core/utils/operators';

interface StorefrontEditingMessage {
type: string;
type: 'sfe-pwaready' | 'sfe-pwanavigation' | 'sfe-pwastable' | 'sfe-setcontext';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any;
}
Expand Down Expand Up @@ -79,7 +78,11 @@ export class PreviewService {
* (3) OR the debug mode is on (`initOnTopLevel`).
*/
private shouldInit() {
return typeof window !== 'undefined' && ((window.parent && window.parent !== window) || this.initOnTopLevel);
return (
typeof window !== 'undefined' &&
((window.parent && window.parent !== window) || this.initOnTopLevel) &&
!this.isDesignViewMode
);
}

/**
Expand Down Expand Up @@ -175,4 +178,9 @@ export class PreviewService {
get previewContextId() {
return this._previewContextId ?? (!SSR ? sessionStorage.getItem('PreviewContextID') : undefined);
}

// TODO: replace usage of previewContextId to identify Design View mode
get isDesignViewMode(): boolean {
return this.previewContextId === 'DESIGNVIEW';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<ng-container *ngIf="isDesignViewMode && type; else cmsOutlet">
<div class="design-view-wrapper" [ngClass]="type">
<div class="design-view-wrapper-actions">
<ng-container [ngSwitch]="type">
<!-- pagelet -->
<ng-template [ngSwitchCase]="'pagelet'">
<ng-container *ngIf="pagelet$ | async as pagelet">
<div class="name">{{ pagelet.displayName ? pagelet.displayName : '(Language missing)' }}</div>
<button
(click)="triggerAction(pagelet.id, 'pageletEdit')"
class="btn"
title="{{ 'designview.edit.link.title' | translate }}"
>
<fa-icon [icon]="['fas', 'pencil-alt']" />
</button>
<!--
<button
(click)="triggerAction(pagelet.id, 'pageletDelete')"
class="btn"
title="{{ 'designview.delete.link.title' | translate }}"
>
<fa-icon [icon]="['fas', 'trash-alt']"></fa-icon>
</button>
-->
</ng-container>
</ng-template>

<!-- slot -->
<ng-template [ngSwitchCase]="'slot'">
<div class="name">{{ pagelet.slot(this.slotId).displayName }}</div>
<button
(click)="triggerAction(this.slotId, 'slotAdd')"
class="btn"
title="{{ 'designview.add.link.title' | translate }}"
>
<fa-icon [icon]="['fas', 'plus']" />
</button>
</ng-template>
<!-- include -->
<ng-template [ngSwitchCase]="'include'">
<div class="name">{{ include.displayName }}</div>
<button
(click)="triggerAction(include.id, 'includeAdd')"
class="btn"
title="{{ 'designview.add.link.title' | translate }}"
>
<fa-icon [icon]="['fas', 'plus']" />
</button>
</ng-template>
</ng-container>
</div>
<ng-container *ngTemplateOutlet="cmsOutlet" />
</div>
</ng-container>

<ng-template #cmsOutlet>
<ng-content></ng-content>
</ng-template>
Loading

0 comments on commit 8452343

Please sign in to comment.