Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: design preview support #1216

Merged
merged 2 commits into from
Jan 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/demo-server-up.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ jobs:
issue-number: ${{ steps.find-pull-request.outputs.number }}
body: |
Azure Demo Servers are available:
- [Universal B2B](https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net/en/home)
- [Universal B2C](https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net/b2c/home)
- [Universal B2B](http://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net/en/home)
- [Universal B2C](http://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net/b2c/home)
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));
shauke marked this conversation as resolved.
Show resolved Hide resolved

// 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