Skip to content

Commit

Permalink
feat: design preview support (#1216)
Browse files Browse the repository at this point in the history
* 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 <s.hauke@intershop.de>
Co-authored-by: Marcel Eisentraut <meisentraut@intershop.de>
  • Loading branch information
3 people committed Jan 16, 2023
1 parent 858ec4d commit 8a22f3c
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 1 deletion.
11 changes: 11 additions & 0 deletions docs/concepts/cms-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/app/core/interceptors/preview.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (this.previewService.previewContextId) {
return next.handle(
req.clone({
url: `${req.url};prectx=${this.previewService.previewContextId}`,
})
);
}

return next.handle(req);
}
}
21 changes: 21 additions & 0 deletions src/app/core/services/preview/preview.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
176 changes: 176 additions & 0 deletions src/app/core/services/preview/preview.service.ts
Original file line number Diff line number Diff line change
@@ -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<StorefrontEditingMessage>();
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<MessageEvent>(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<NavigationEnd>(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);
}
}
2 changes: 1 addition & 1 deletion src/app/pages/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit 8a22f3c

Please sign in to comment.