From 8a22f3c7f378007e81e07d82d088652b4b4962f7 Mon Sep 17 00:00:00 2001 From: Johannes Metzner Date: Tue, 1 Mar 2022 18:42:49 +0100 Subject: [PATCH] feat: design preview support (#1216) * PreviewContextID interceptor adds PreviewContextID to REST calls * PreviewService for PreviewContextID handling (set, update, delete) * changed `{ path: '', redirectTo: 'home', pathMatch: 'full' }` to prevent PreviewContextID query parameter is removed on redirect for empty route path * documentation (NOT full Design View) Co-authored-by: Stefan Hauke Co-authored-by: Marcel Eisentraut --- docs/concepts/cms-integration.md | 11 ++ src/app/core/core.module.ts | 2 + .../core/interceptors/preview.interceptor.ts | 25 +++ .../services/preview/preview.service.spec.ts | 21 +++ .../core/services/preview/preview.service.ts | 176 ++++++++++++++++++ src/app/pages/app-routing.module.ts | 2 +- 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/app/core/interceptors/preview.interceptor.ts create mode 100644 src/app/core/services/preview/preview.service.spec.ts create mode 100644 src/app/core/services/preview/preview.service.ts diff --git a/docs/concepts/cms-integration.md b/docs/concepts/cms-integration.md index 64a647188d..b45dde257e 100644 --- a/docs/concepts/cms-integration.md +++ b/docs/concepts/cms-integration.md @@ -87,6 +87,17 @@ CREATE src/app/cms/components/cms-inventory/cms-inventory.component.spec.ts (795 UPDATE src/app/cms/cms.module.ts (4956 bytes) ``` +## 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 [`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. + ## Integration with an External CMS Since the Intershop PWA can integrate any other REST API in addition to the ICM REST API, it should not be a problem to integrate an external 3rd party CMS that provides an own REST API, instead of using the integrated ICM CMS. diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e2d61dd53e..a8c30cdaf9 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -12,6 +12,7 @@ import { ICMErrorMapperInterceptor } from './interceptors/icm-error-mapper.inter import { IdentityProviderInterceptor } from './interceptors/identity-provider.interceptor'; import { MockInterceptor } from './interceptors/mock.interceptor'; import { PaymentPayoneInterceptor } from './interceptors/payment-payone.interceptor'; +import { PreviewInterceptor } from './interceptors/preview.interceptor'; import { InternationalizationModule } from './internationalization.module'; import { StateManagementModule } from './state-management.module'; import { DefaultErrorHandler } from './utils/default-error-handler'; @@ -36,6 +37,7 @@ import { DefaultErrorHandler } from './utils/default-error-handler'; }, { provide: HTTP_INTERCEPTORS, useClass: PaymentPayoneInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: MockInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: PreviewInterceptor, multi: true }, { provide: ErrorHandler, useClass: DefaultErrorHandler }, { provide: APP_BASE_HREF, diff --git a/src/app/core/interceptors/preview.interceptor.ts b/src/app/core/interceptors/preview.interceptor.ts new file mode 100644 index 0000000000..dd81fc8e46 --- /dev/null +++ b/src/app/core/interceptors/preview.interceptor.ts @@ -0,0 +1,25 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { PreviewService } from 'ish-core/services/preview/preview.service'; + +/** + * add PreviewContextID to every request if it is available in the SessionStorage + */ +@Injectable() +export class PreviewInterceptor implements HttpInterceptor { + constructor(private previewService: PreviewService) {} + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (this.previewService.previewContextId) { + return next.handle( + req.clone({ + url: `${req.url};prectx=${this.previewService.previewContextId}`, + }) + ); + } + + return next.handle(req); + } +} diff --git a/src/app/core/services/preview/preview.service.spec.ts b/src/app/core/services/preview/preview.service.spec.ts new file mode 100644 index 0000000000..c8e24f2d34 --- /dev/null +++ b/src/app/core/services/preview/preview.service.spec.ts @@ -0,0 +1,21 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { provideMockStore } from '@ngrx/store/testing'; + +import { PreviewService } from './preview.service'; + +describe('Preview Service', () => { + let previewService: PreviewService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [provideMockStore()], + }); + previewService = TestBed.inject(PreviewService); + }); + + it('should be created', () => { + expect(previewService).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/preview/preview.service.ts b/src/app/core/services/preview/preview.service.ts new file mode 100644 index 0000000000..69f640bd6b --- /dev/null +++ b/src/app/core/services/preview/preview.service.ts @@ -0,0 +1,176 @@ +/* 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'; +import { Subject, delay, filter, first, fromEvent, map, race, switchMap, take, timer, withLatestFrom } from 'rxjs'; + +import { getICMBaseURL } from 'ish-core/store/core/configuration'; +import { whenTruthy } from 'ish-core/utils/operators'; + +interface StorefrontEditingMessage { + type: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload?: any; +} + +interface SetPreviewContextMessage extends StorefrontEditingMessage { + payload?: { + previewContextID: string; + }; +} + +@Injectable({ providedIn: 'root' }) +export class PreviewService { + private allowedHostMessageTypes = ['sfe-setcontext']; + private initOnTopLevel = false; // for debug purposes. enables this feature even in top-level windows + + private hostMessagesSubject$ = new Subject(); + private _previewContextId: string; + + constructor( + private router: Router, + private store: Store, + private appRef: ApplicationRef, + private route: ActivatedRoute + ) { + this.init(); + race([ + this.route.queryParams.pipe( + filter(params => params.PreviewContextID), + map(params => params.PreviewContextID), + take(1) + ), + // end listening for PreviewContextID if there is no such parameter at initialization + timer(3000), + ]).subscribe(value => { + if (!this.previewContextId && value) { + this.previewContextId = value; + } + }); + } + + /** + * Start method that sets up SFE communication. + * Needs to be called *once* for the whole application, e.g. in the `AppModule` constructor. + */ + private init() { + if (!this.shouldInit()) { + return; + } + + this.listenToHostMessages(); + this.listenToApplication(); + + this.hostMessagesSubject$.asObservable().subscribe(message => this.handleHostMessage(message)); + + // Initial startup message to the host + this.store.pipe(select(getICMBaseURL), take(1)).subscribe(icmBaseUrl => { + this.messageToHost({ type: 'sfe-pwaready' }, icmBaseUrl); + }); + } + + /** + * Decides whether to init the SFE 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) + * (3) OR the debug mode is on (`initOnTopLevel`). + */ + private shouldInit() { + return typeof window !== 'undefined' && ((window.parent && window.parent !== window) || this.initOnTopLevel); + } + + /** + * Subscribe to messages from the host window (i.e. from the Design View). + * Incoming messages are filtered by allow list (`allowedMessages`). + * Should only be called *once* during initialization. + */ + private listenToHostMessages() { + fromEvent(window, 'message') + .pipe( + withLatestFrom(this.store.pipe(select(getICMBaseURL))), + filter( + ([e, icmBaseUrl]) => + e.origin === icmBaseUrl && + e.data.hasOwnProperty('type') && + this.allowedHostMessageTypes.includes(e.data.type) + ), + map(([message]) => message.data) + ) + .subscribe(this.hostMessagesSubject$); + } + + /** + * Listen to events throughout the applicaton and send message to host when + * (1) route has changed (`sfe-pwanavigation`), + * (2) application is stable, i.e. all async tasks have been completed (`sfe-pwastable`) or + * (3) content include has been reloaded (`sfe-pwastable`). + * + * The stable event is the notifier for the design view to rerender the component tree view. + * The event contains the tree, created by `analyzeTree()`. + * + * Should only be called *once* during initialization. + */ + private listenToApplication() { + const navigation$ = this.router.events.pipe(filter(e => e instanceof NavigationEnd)); + + const stable$ = this.appRef.isStable.pipe(whenTruthy(), first()); + + const navigationStable$ = navigation$.pipe(switchMap(() => stable$)); + + // send `sfe-pwanavigation` event for each route change + navigation$ + .pipe(withLatestFrom(this.store.pipe(select(getICMBaseURL)))) + .subscribe(([e, icmBaseUrl]) => + this.messageToHost({ type: 'sfe-pwanavigation', payload: { url: e.url } }, icmBaseUrl) + ); + + // send `sfe-pwastable` event when application is stable or loading of the content included finished + navigationStable$ + .pipe( + withLatestFrom(this.store.pipe(select(getICMBaseURL))), + delay(1000) // # animation-delay (css-transition) + ) + .subscribe(([, icmBaseUrl]) => this.messageToHost({ type: 'sfe-pwastable' }, icmBaseUrl)); + } + + /** + * Send a message to the host window + * + * @param message The message to send (including type and payload) + * @param hostOrigin The window to send the message to. This is necessary due to cross-origin policies. + */ + private messageToHost(message: StorefrontEditingMessage, hostOrigin: string) { + window.parent.postMessage(message, hostOrigin); + } + + /** + * Handle incoming message from the host window. + * Invoked by the event listener in `listenToHostMessages()` when a new message arrives. + */ + private handleHostMessage(message: StorefrontEditingMessage) { + switch (message.type) { + case 'sfe-setcontext': { + const previewContextMsg: SetPreviewContextMessage = message; + this.previewContextId = previewContextMsg?.payload?.previewContextID; + location.reload(); + return; + } + } + } + + set previewContextId(previewContextId: string) { + this._previewContextId = previewContextId; + if (!SSR) { + if (previewContextId) { + sessionStorage.setItem('PreviewContextID', previewContextId); + } else { + sessionStorage.removeItem('PreviewContextID'); + } + } + } + + get previewContextId() { + return this._previewContextId ?? (!SSR ? sessionStorage.getItem('PreviewContextID') : undefined); + } +} diff --git a/src/app/pages/app-routing.module.ts b/src/app/pages/app-routing.module.ts index f8eaea3c90..1a83f6127d 100644 --- a/src/app/pages/app-routing.module.ts +++ b/src/app/pages/app-routing.module.ts @@ -9,7 +9,7 @@ import { IdentityProviderLogoutGuard } from 'ish-core/guards/identity-provider-l import { IdentityProviderRegisterGuard } from 'ish-core/guards/identity-provider-register.guard'; const routes: Routes = [ - { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'loading', loadChildren: () => import('./loading/loading-page.module').then(m => m.LoadingPageModule) }, { path: 'home',