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

docs: documentation for product contexts #608

Merged
merged 6 commits into from
Mar 9, 2021
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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ kb_sync_latest_only
- [Concept - Project Structure](./concepts/project-structure.md)
- [Concept - State Management](./concepts/state-management.md)
- [Guide - State Management](./guides/state-management.md)
- [Guide - Product Context](./guides/product-context.md)
- [Concept - CMS Integration](./concepts/cms-integration.md)
- [Concept - Configuration](./concepts/configuration.md)
- [Guide - Propagating Environment Variables](./guides/propagating-environment-variables.md)
Expand Down
1 change: 1 addition & 0 deletions docs/concepts/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Context facades are provided using [`ElementInjector`](https://angular.io/guide/
- Provided via directives on the template

For a more detailed introduction see [here][facades-meetup].
Extensive documentation for the [Product Context Facade](../guides/product-context.md) is also available.

## File Structure

Expand Down
Binary file added docs/guides/product-context-bundle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/guides/product-context-listing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/guides/product-context-recommendations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/guides/product-context-wishlists.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
171 changes: 171 additions & 0 deletions docs/guides/product-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<!--
kb_guide
kb_pwa
kb_everyone
kb_sync_latest_only
-->

# Product Context

Product Contexts were introduced in PWA version [0.27](https://github.com/intershop/intershop-pwa/releases/tag/0.27.0).

## What is a Product Context?

Product contexts provide easy access to all data related to a single product.
The context itself stores the `sku` and (optional) the `quantity` of the product and delegates all other information from the [State Management][state-management] in a simplified fashion.

The following screenshots provide examples where product contexts are used on some pages:

<div style="text-align: center;">
<a target="_blank" href="product-context-recommendations.png"><img src="product-context-recommendations.png" alt="Product contexts used for recommendations" width="23%"/></a>
<a target="_blank" href="product-context-listing.png"><img src="product-context-listing.png" alt="Product contexts used for product listings" width="23%"/></a>
<a target="_blank" href="product-context-bundle.png"><img src="product-context-bundle.png" alt="Product contexts used for product bundles" width="23%"/></a>
<a target="_blank" href="product-context-wishlists.png"><img src="product-context-wishlists.png" alt="Product contexts used for wish lists" width="23%"/></a>
</div>

For product pages, there is always one context that spans the entire content of the product page.
However, additional contexts for recommended products or bundled products may exist on the page.
For listings of any kind, individual contexts exist on the page for each product.
Product Contexts can also be linked to their parent contexts.

## How to Introduce Product Context?

Unlike regular facades, which are available globally and have only one instance available at runtime, context facades are provided using [`ElementInjector`](https://angular.io/guide/hierarchical-dependency-injection#elementinjector) and are therefore only available for elements enclosed in the document subtree for which the element introduced the context.

### By Using [`ProductContextDirective`][src-product-context-directive]

The easiest way to start a product context is by using the [`ProductContextDirective`][src-product-context-directive] on templates:

```html
<ng-container *ngFor="let item of lineItems$ | async">
<div class="..." ishProductContext [sku]="item.sku">
<ish-product-name></ish-product-name>
</div>
</ng-container>
```

What happens here?

- For each individual `item` from `lineItems$`, a product context for embedded elements is provided.
- Each individual context is associated with `item.sku` which also triggers fetching the product if it is not already available in the store.
- The `ish-product-name` component will render the product name.

This also portrays one of the main advantages of using this concept: No bubbling of product related data is necessary anymore.
The `ish-product-name` component injects the provided context and decides which data is used for rendering.

Note: [`ProductDetailComponent`](../../src/app/pages/product/product-detail/product-detail.component.html) is only used for layout and styling, whereas the context is provided by the outer [`ProductPageComponent`][src-product-page-component-ts].

### By Providing [`ProductContextFacade`][src-product-context-facade]

There are cases for which it is not possible to use the directive: For example, when the parent component does not have access to all required information or when the component has to introduce another context by itself.
In this case the [`ProductContextFacade`][src-product-context-facade] has to be added to the `providers` array of the [`@Component`](https://angular.io/api/core/Component) decorator.
Afterwards, the SKU for the context has to be initialized:

```typescript
@Component({
...
providers: [ProductContextFacade],
})
export class MyComponent implements OnInit {
constructor(private context: ProductContextFacade) {}

ngOnInit() {
this.context.set('sku', () => this.staticValue);
// or
this.context.connect('sku', this.streamValue$);
}
}
```

### By Providing [`SelectedProductContextFacade`][src-selected-product-context-facade]

For pages that use the `:sku` route parameter, the product context can be introduced on page level by using [`SelectedProductContextFacade`][src-selected-product-context-facade].

Have a look at [`ProductPageComponent`][src-product-page-component-ts] for an example.

## Retrieving Data from the Product Context

### Inject [`ProductContextFacade`][src-product-context-facade]

The easiest way to interact with the context is to do the same as you would do with facades.
Inject the [`ProductContextFacade`][src-product-context-facade] into your components and relay data for the template into observables and use the [async pipe](https://angular.io/guide/observables-in-angular#async-pipe) there:

```typescript
@Component({ ... })
export class MyComponent implements OnInit {
available$: Observable<boolean>;

constructor(private context: ProductContextFacade) {}

ngOnInit() {
this.available$ = this.context.select('product', 'available');
}

doSomething() {
this.context.doSomething();
}
}
```

```html
<ng-container *ngIf="available$ | async">...</ng-container>
```

### Use [`ProductContextAccessDirective`][src-product-context-access-directive]

If access to the context is required in the template without injecting it into the component (i.e. if the component has multiple embedded contexts), the [`ProductContextAccessDirective`][src-product-context-access-directive] can be used:

```html
<ng-container *ngFor="let item of lineItems$ | async">
<div class="..." ishProductContext [sku]="item.sku">
<ng-container *ishProductContextAccess="let product = product; let context = context">
{{ product.sku }}
<input type="button" (click)="context.doSomething()" />
</ng-container>
</div>
</ng-container>
```

In this example, the product context is created for each `item` and afterwards the property `product` from the context is used with `ishProductContextAccess` and thereby made available to the embedded template.

This feature should only be used for edge cases, as it is very verbose and most of the time a proper refactoring of the embedded template into a new component can improve the code style.

## Linking Embedded Contexts

There are cases where product contexts are used as meta contexts for handling add-to-cart functionality and validations thereof.
For [product retail sets](https://intershoppwa.azurewebsites.net/skuM4548736000919) the surrounding product context is used as a meta context with two add-to-cart buttons (one at the detail on top, one at the button after the listing for contained items).
If those buttons are pressed, the correct amount for quantities is used to perform the add-to-cart action.
Those buttons also disable themselves if the quantity of one individual retail set part exceeds the maximum allowed order quantity.

All of this is also handled in the [`ProductContextFacade`][src-product-context-facade] if the embedded contexts are initialized using [`ProductContextDirective`][src-product-context-directive] and properly mapped `propagateIndex`.

Currently this feature is only used for enabling or disabling add-to-cart functionality, though.

## Customizing Display Properties

Product contexts also contain all logic for deciding if specific functionality is available for each individual product.
Each component that handles items of this configuration checks if they are active themselves.

This functionality can be customized by changing the [`defaultDisplayProperties`][src-product-context-facade].

A more elaborate way would be to provide an [`ExternalDisplayPropertiesProvider`][src-product-context-facade].
Have a look at [`PunchoutProductContextDisplayPropertiesService`](../../src/app/extensions/punchout/services/punchout-product-context-display-properties/punchout-product-context-display-properties.service.ts) or [`TactonProductContextDisplayPropertiesService`](../../src/app/extensions/tacton/services/tacton-product-context-display-properties/tacton-product-context-display-properties.service.ts) for examples.

# Further References

- Product Context Artifacts
- [`ProductContext`][src-product-context-facade]
- [`ProductContextFacade`][src-product-context-facade]
- [`SelectedProductContextFacade`][src-selected-product-context-facade]
- [`ProductContextDirective`][src-product-context-directive]
- [`ProductContextAccessDirective`][src-product-context-access-directive]
- [`ExternalDisplayPropertiesProvider`][src-product-context-facade]
- [Concept - State Management][state-management]
- [Facades – The Best Layer of your Angular Application @ ngLeipzig #36](https://www.youtube.com/watch?v=I14r3joLu9A)

[state-management]: ../concepts/state-management.md
[src-product-context-facade]: ../../src/app/core/facades/product-context.facade.ts
[src-selected-product-context-facade]: ../../src/app/core/facades/selected-product-context.facade.ts
[src-product-context-directive]: ../../src/app/core/directives/product-context.directive.ts
[src-product-context-access-directive]: ../../src/app/core/directives/product-context-access.directive.ts
[src-product-page-component-ts]: ../../src/app/pages/product/product-page.component.ts
3 changes: 3 additions & 0 deletions src/app/core/directives.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { ClickOutsideDirective } from './directives/click-outside.directive';
import { IdentityProviderCapabilityDirective } from './directives/identity-provider-capability.directive';
import { IntersectionObserverDirective } from './directives/intersection-observer.directive';
import { ProductContextAccessDirective } from './directives/product-context-access.directive';
import { ProductContextDirective } from './directives/product-context.directive';
import { ServerHtmlDirective } from './directives/server-html.directive';

Expand All @@ -11,13 +12,15 @@ import { ServerHtmlDirective } from './directives/server-html.directive';
ClickOutsideDirective,
IdentityProviderCapabilityDirective,
IntersectionObserverDirective,
ProductContextAccessDirective,
ProductContextDirective,
ServerHtmlDirective,
],
exports: [
ClickOutsideDirective,
IdentityProviderCapabilityDirective,
IntersectionObserverDirective,
ProductContextAccessDirective,
ProductContextDirective,
ServerHtmlDirective,
],
Expand Down
47 changes: 47 additions & 0 deletions src/app/core/directives/product-context-access.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Directive, EmbeddedViewRef, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { ProductContext, ProductContextFacade } from 'ish-core/facades/product-context.facade';

type ProductContextAccessContext = ProductContext & { context: ProductContextFacade };

@Directive({
selector: '[ishProductContextAccess]',
})
export class ProductContextAccessDirective implements OnDestroy {
private view: EmbeddedViewRef<ProductContextAccessContext>;
private destroy$ = new Subject();

constructor(
context: ProductContextFacade,
viewContainer: ViewContainerRef,
template: TemplateRef<ProductContextAccessContext>
) {
context
.select()
.pipe(takeUntil(this.destroy$))
.subscribe(ctx => {
if (!this.view && ctx?.product) {
this.view = viewContainer.createEmbeddedView(template, { ...ctx, context });
} else if (this.view && ctx?.product) {
Object.keys(ctx).forEach(key => {
this.view.context[key] = ctx[key];
});
}

if (this.view) {
this.view.markForCheck();
}
});
}

static ngTemplateContextGuard(_: ProductContextAccessDirective, ctx: unknown): ctx is ProductContextAccessContext {
return !!ctx || true;
}

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
2 changes: 1 addition & 1 deletion src/app/core/facades/product-context.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const EXTERNAL_DISPLAY_PROPERTY_PROVIDER = new InjectionToken<ExternalDis
'externalDisplayPropertiesProvider'
);

interface ProductContext {
export interface ProductContext {
sku: string;
requiredCompletenessLevel: ProductCompletenessLevel | true;
product: AnyProductViewType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ <h2 class="modal-title">{{ headerTranslationKey | translate }}</h2>
</ng-container>

<ng-template #showSuccess>
<div class="modal-body">
<div class="modal-body" *ishProductContextAccess="let product = product">
<span
[ishServerHtml]="
successTranslationKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { of } from 'rxjs';
import { anything, capture, instance, mock, spy, verify, when } from 'ts-mockito';

import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive';
import { ProductContextFacade } from 'ish-core/facades/product-context.facade';
import { InputComponent } from 'ish-shared/forms/components/input/input.component';

import { OrderTemplatesFacade } from '../../facades/order-templates.facade';
Expand Down Expand Up @@ -36,10 +35,7 @@ describe('Select Order Template Modal Component', () => {
SelectOrderTemplateModalComponent,
],
imports: [NgbModalModule, ReactiveFormsModule, TranslateModule.forRoot()],
providers: [
{ provide: OrderTemplatesFacade, useFactory: () => instance(orderTemplateFacadeMock) },
{ provide: ProductContextFacade, useFactory: () => instance(mock(ProductContextFacade)) },
],
providers: [{ provide: OrderTemplatesFacade, useFactory: () => instance(orderTemplateFacadeMock) }],
}).compileComponents();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';

import { ProductContextFacade } from 'ish-core/facades/product-context.facade';
import { SelectOption } from 'ish-shared/forms/components/select/select.component';
import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils';

Expand Down Expand Up @@ -58,8 +57,7 @@ export class SelectOrderTemplateModalComponent implements OnInit, OnDestroy {
private ngbModal: NgbModal,
private fb: FormBuilder,
private translate: TranslateService,
private orderTemplatesFacade: OrderTemplatesFacade,
private context: ProductContextFacade
private orderTemplatesFacade: OrderTemplatesFacade
) {}

ngOnInit() {
Expand Down Expand Up @@ -220,8 +218,4 @@ export class SelectOrderTemplateModalComponent implements OnInit, OnDestroy {
? 'account.order_template.added.confirmation'
: 'account.order_template.move.added.text';
}

get product() {
return this.context.get('product');
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ng-container *ngIf="product$ | async as product">
<ng-container *ishProductContextAccess="let product = product">
<div class="row" data-testing-id="wishlist-product">
<div class="col-3 col-md-2 list-item">
<ish-product-image imageType="S" [link]="true"></ish-product-image>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { TranslateModule } from '@ngx-translate/core';
import { MockComponent, MockPipe } from 'ng-mocks';
import { instance, mock } from 'ts-mockito';

import { ProductContextFacade } from 'ish-core/facades/product-context.facade';
import { DatePipe } from 'ish-core/pipes/date.pipe';
import { ProductAddToBasketComponent } from 'ish-shared/components/product/product-add-to-basket/product-add-to-basket.component';
import { ProductBundleDisplayComponent } from 'ish-shared/components/product/product-bundle-display/product-bundle-display.component';
Expand Down Expand Up @@ -43,11 +42,7 @@ describe('Account Wishlist Detail Line Item Component', () => {
],
imports: [TranslateModule.forRoot()],
providers: [{ provide: WishlistsFacade, useFactory: () => instance(mock(WishlistsFacade)) }],
})
.overrideComponent(AccountWishlistDetailLineItemComponent, {
set: { providers: [{ provide: ProductContextFacade, useFactory: () => instance(mock(ProductContextFacade)) }] },
})
.compileComponents();
}).compileComponents();
});

beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
import { Observable } from 'rxjs';

import { ProductContextFacade } from 'ish-core/facades/product-context.facade';
import { ProductView } from 'ish-core/models/product-view/product-view.model';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

import { WishlistsFacade } from '../../../facades/wishlists.facade';
import { Wishlist, WishlistItem } from '../../../models/wishlist/wishlist.model';
Expand All @@ -14,24 +10,13 @@ import { Wishlist, WishlistItem } from '../../../models/wishlist/wishlist.model'
selector: 'ish-account-wishlist-detail-line-item',
templateUrl: './account-wishlist-detail-line-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ProductContextFacade],
})
export class AccountWishlistDetailLineItemComponent implements OnChanges, OnInit {
constructor(private wishlistsFacade: WishlistsFacade, private context: ProductContextFacade) {}
export class AccountWishlistDetailLineItemComponent {
constructor(private wishlistsFacade: WishlistsFacade) {}

@Input() wishlistItemData: WishlistItem;
@Input() currentWishlist: Wishlist;

product$: Observable<ProductView>;

ngOnInit() {
this.product$ = this.context.select('product');
}

ngOnChanges() {
this.context.set('sku', () => this.wishlistItemData.sku);
}

moveItemToOtherWishlist(sku: string, wishlistMoveData: { id: string; title: string }) {
if (wishlistMoveData.id) {
this.wishlistsFacade.moveItemToWishlist(this.currentWishlist.id, wishlistMoveData.id, sku);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ <h1>{{ wishlist?.title }}</h1>
<ng-container *ngFor="let item of wishlist.items">
<div class="list-item-row">
<ish-account-wishlist-detail-line-item
ishProductContext
[sku]="item.sku"
[wishlistItemData]="item"
[currentWishlist]="wishlist"
></ish-account-wishlist-detail-line-item>
Expand Down
Loading