Advanced
https://atomicdesign.bradfrost.com/chapter-2/
https://atomicdesign.bradfrost.com/chapter-2/
https://atomicdesign.bradfrost.com/chapter-2/
https://atomicdesign.bradfrost.com/chapter-2/
https://atomicdesign.bradfrost.com/chapter-2/
https://atomicdesign.bradfrost.com/chapter-2/
Angular Material!
Thumbnail
Subtitle
Title
Rating
Price
"Build components without starting a complex dev setup, force certain data into your database, or navigate around your application"
Portrait
landscape
Primary
Max
Min
Max
Min
Max
Min
Primary
Primary
Primary
Max
Min
Max
Min
<section class="product">
<ov-thumbnail></ov-thumbnail>
<div class="info">
<ov-sub-title></ov-sub-title>
<ov-title></ov-title>
<div class="details">
<div>
<ov-rating></ov-rating>
<ov-price></ov-price>
</div>
</div>
</div>
</section>
<section class="product">
<ov-thumbnail></ov-thumbnail>
<div class="info">
<ov-sub-title></ov-sub-title>
<ov-title></ov-title>
<div class="details">
<div>
<ov-rating></ov-rating>
<ov-price></ov-price>
</div>
<div>
<ov-quantity-picker>
</ov-quantity-picker>
</div>
</div>
<div class="footer">
<ov-message type="ok"></ov-message>
</div>
</div>
</section>
<section class="product">
<ov-thumbnail></ov-thumbnail>
<div class="info">
<ov-sub-title></ov-sub-title>
<ov-title></ov-title>
<div class="details">
<div>
<ov-rating></ov-rating>
<ov-price></ov-price>
</div>
<div>
<ov-quantity-picker>
</ov-quantity-picker>
</div>
</div>
<div class="footer">
<ov-message type="ok"
*ngIf="!isLimited; else limited">
</ov-message>
<ng-template #limited>
<ov-message type="warn"></ov-message>
</ng-template>
</div>
</div>
</section>
<section class="product">
<ov-thumbnail></ov-thumbnail>
<div class="info">
<ov-sub-title></ov-sub-title>
<ov-title></ov-title>
<div class="details">
<div>
<ov-rating></ov-rating>
<ov-price></ov-price>
</div>
<div>
<ov-quantity-picker
*ngIf="!isOutOfStock; else outOfStock">
</ov-quantity-picker>
<ng-template #outOfStock>
<ov-button>
<ov-icon></ov-icon>
</ov-button>
</ng-template>
</div>
</div>
<div class="footer">
<ov-message type="ok"
*ngIf="!isLimited">
</ov-message>
<ov-message type="warn"
*ngIf="isLimited">
</ov-message>
<ov-message type="error"
*ngIf="isOutOfStock">
</ov-message>
</div>
</div>
</section>
<section class="product" *ngIf="!isReplaced">
<ov-thumbnail></ov-thumbnail>
<div class="info">
<ov-sub-title></ov-sub-title>
<ov-title></ov-title>
<div class="details">
<div>
<ov-rating></ov-rating>
<ov-price></ov-price>
</div>
<div>
<ov-quantity-picker
*ngIf="!isOutOfStock; else outOfStock">
</ov-quantity-picker>
<ng-template #outOfStock>
<ov-button></ov-button>
</ng-template>
</div>
</div>
<div class="footer">
<ov-message type="ok"
*ngIf="!isLimited">
</ov-message>
<ov-message type="warn"
*ngIf="isLimited">
</ov-message>
<ov-message type="error"
*ngIf="isOutOfStock">
</ov-message>
</div>
</div>
</section>
<section *ngIf="isReplaced">
...
</section>
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
}
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
}
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
@Input() isOutOfStock = false;
}
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
@Input() isOutOfStock = false;
@Input() isReplaced? = false;
@Input() replacement?: Product;
}
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
@Input() isOutOfStock = false;
@Input() isReplaced? = false;
@Input() replacement?: Product;
@Output() addToCart = new EventEmitter<number>();
@Output() contactUs = new EventEmitter<Product>();
}
Slots
single-slot
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'ov-sub-title',
template: `
<span>
<ng-content></ng-content>
</span>
`,
styleUrls: ['./sub-title.component.scss'],
})
export class SubTitleComponent implements OnInit {
constructor() {}
ngOnInit(): void {}
}
<ov-sub-title>My subtitle</ov-sub-title>
multi-slot
<ov-product>
<section actions>...</section>
<section footer>...</section>
</ov-product>
<section class="product">
...
<div class="info">
...
<div class="details">
...
<div>
<ng-content select="[actions]"></ng-content>
</div>
</div>
<div class="footer">
<ng-content select="[footer]"></ng-content>
</div>
</div>
</section>
<ov-product [product]="product">
<section actions>
<ov-quantity-picker
[quantity]="product.quantity"
(selectQuantity)="selectQuantity($event)">
</ov-quantity-picker>
</section>
<section footer>
<ov-message type="ok"
*ngIf="!product.isLimited; else limited">
This product is in stock
</ov-message>
<ng-template #limited>
<ov-message type="warn" #limited>
Only {{ product.quantity.max }} left
</ov-message>
</ng-template>
</section>
</ov-product>
Product
ProductDefault
<section class="product">
...
<div class="info">
...
<div class="details">
...
<div>
<ng-content select="[actions]">
</ng-content>
</div>
</div>
<div class="footer">
<ng-content select="[footer]">
</ng-content>
</div>
</div>
</section>
<ov-product [product]="product">
<section actions>
<ov-button type="inverted">
<ov-icon icon="envelope"
color="primary"
size="xs">
</ov-icon>
Contact
</ov-button>
</section>
<section footer>
<ov-message type="error">
This product is out of stock
</ov-message>
</section>
</ov-product>
Product
ProductOutOfStock
<section class="product">
...
<div class="info">
...
<div class="details">
...
<div>
<ng-content select="[actions]">
</ng-content>
</div>
</div>
<div class="footer">
<ng-content select="[footer]">
</ng-content>
</div>
</div>
</section>
Product
ProductDefault
ProductOutOfStock
Parent
ProductDefault
Product
event
event
<ov-product [product]="product">
<section actions>
<ov-button (click)="emitEvent()">
DoSomething
</ov-button>
</section>
<section footer>
...
</section>
</ov-product>
$xs: 0px;
$sm: 576px;
$md: 768px;
$lg: 992px;
$xl: 1200px;
@mixin breakpoint-xs {
@media (min-width: $xs) {
@content;
}
}
@mixin breakpoint-sm {
@media (min-width: $sm) {
@content;
}
}
@mixin breakpoint-md {
@media (min-width: $md) {
@content;
}
}
...
@import "../../_styles/breakpoint.scss";
.side {
display: none;
@include breakpoint-lg {
min-width: 340px;
display: block;
}
}
conditional
DEMO
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'test',
})
export class TestPipe implements PipeTransform {
transform(value: unknown, ...args: unknown[]): unknown {
return null;
}
}
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'test',
pure: true, // default
})
export class TestPipe implements PipeTransform {
transform(value: unknown, ...args: unknown[]): unknown {
return null;
}
}
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'test',
pure: true, // default
})
export class TestPipe implements PipeTransform {
transform(value: unknown, ...args: unknown[]): unknown {
return null;
}
}
interface Quantity {
min: number;
step: number;
max: number;
}
import { Pipe, PipeTransform } from '@angular/core';
import { Quantity } from '../quantity-picker.component';
export type Option = number | 'More';
const MAX_AMOUNT_OF_OPTIONS = 8;
@Pipe({
name: 'quantityOptions',
})
export class QuantityOptionsPipe implements PipeTransform {
transform({ min, max, step }: Quantity): Option[] {
const options: Option[] = [];
for (let i = min; i <= max; i += step) {
options.push(i);
if (options.length >= MAX_AMOUNT_OF_OPTIONS) {
break;
}
}
if (options[options.length - 1] < max) {
options.push('More');
}
return options;
}
}
<select>
<option *ngFor="let option of quantity | quantityOptions">
{{ option }}
</option>
</select>
<div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById"
[class.odd]="odd">
({{i}}) {{hero.name}}
</div>
<div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById"
[class.odd]="odd">
({{i}}) {{hero.name}}
</div>
<ng-template ngFor let-hero [ngForOf]="heroes" let-i="index"
let-odd="odd" [ngForTrackBy]="trackById">
<div [class.odd]="odd">({{i}}) {{hero.name}}</div>
</ng-template>
@Directive({
selector: '[ovIfBreakpoint]',
})
export class IfBreakpointDirective implements OnDestroy {
@Input('ovIfBreakpoint')
breakpoint!: Breakpoint;
private subscription!: Subscription;
constructor(
private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef,
private breakpointService: BreakpointService
) {}
ngOnInit() {
this.subscription = this.breakpointService.breakpoint$
.subscribe(
(breakpoint) => {
if (breakpoint === this.breakpoint) {
this.viewContainerRef.createEmbeddedView(this.templateRef);
return;
}
if (this.viewContainerRef.length > 0) {
this.viewContainerRef.clear();
return;
}
}
);
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}
<div *ovIfBreakpoint="'xs'">
Show me on xs!
</div>
@Input('ovIfBreakpoint')
breakpoint!: Breakpoint;
...
ngOnInit() {
this.subscription = this.breakpointService.breakpoint$
.subscribe(
(breakpoint) => {
if (breakpoint === this.breakpoint) {
this.viewContainerRef.createEmbeddedView(this.templateRef);
return;
}
if (this.viewContainerRef.length > 0) {
this.viewContainerRef.clear();
return;
}
}
);
}
@Input('ovIfBreakpoint')
breakpoint!: Breakpoint;
@Input('ovIfBreakpointTo')
to?: Breakpoint;
ngOnInit() {
this.subscription = this.breakpointService.breakpoint$.subscribe(
(breakpoint) => {
if (this.shouldDisplay(breakpoint)) {
if (this.viewContainerRef.length > 0) {
return;
}
this.viewContainerRef.createEmbeddedView(this.templateRef);
} else if (this.viewContainerRef.length > 0) {
this.viewContainerRef.clear();
return;
}
}
);
}
private shouldDisplay(breakpoint: Breakpoint) {
return (
breakpoint === this.breakpoint ||
this.isBetweenBreakpoints(breakpoint)
);
}
private isBetweenBreakpoints(breakpoint: Breakpoint) {
if (this.to === undefined) {
return false;
}
const index = BREAKPOINT_ORDER.indexOf(breakpoint);
return (
index >= BREAKPOINT_ORDER.indexOf(this.breakpoint) &&
index <= BREAKPOINT_ORDER.indexOf(this.to)
);
}
<div *ovIfBreakpoint="'xs'; to: 'md'">
Show me on xs, sm and md!
</div>
@Input('ovIfBreakpoint')
breakpoint!: Breakpoint;
@Input('ovIfBreakpointTo')
to?: Breakpoint;
ngOnInit() {
this.subscription = this.breakpointService.breakpoint$.subscribe(
(breakpoint) => {
if (this.shouldDisplay(breakpoint)) {
if (this.viewContainerRef.length > 0) {
return;
}
this.viewContainerRef.createEmbeddedView(this.templateRef);
} else if (this.viewContainerRef.length > 0) {
this.viewContainerRef.clear();
return;
}
}
);
}
private shouldDisplay(breakpoint: Breakpoint) {
return (
breakpoint === this.breakpoint ||
this.isBetweenBreakpoints(breakpoint)
);
}
private isBetweenBreakpoints(breakpoint: Breakpoint) {
if (this.to === undefined) {
return false;
}
const index = BREAKPOINT_ORDER.indexOf(breakpoint);
return (
index >= BREAKPOINT_ORDER.indexOf(this.breakpoint) &&
index <= BREAKPOINT_ORDER.indexOf(this.to)
);
}
Default
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
?
@Component({
...
changeDetection: ChangeDetectionStrategy.OnPush,
})
class ExampleComponent {
constructor(private ref: ChangeDetectorRef) {
setTimeout(() => {
// require this component's view to be updated
this.ref.markForCheck();
// require this component's view and children views to be updated
this.ref.detectChanges();
// throws an error if this component or its children has changes
this.ref.checkNoChanges();
// detach this component completely from change detection
this.ref.detach();
// reattach this component to change detaction
this.ref.reattach();
}, 1000);
}
}
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
markForCheck()
A Zone is an execution context that persists across async tasks. You can think of it as thread-local storage for JavaScript VMs.
ZoneJS
Angular Zone
Outside
externalLib.request()
then(() => {
// outside zone
});
window.onresize = () => {
// outside zone
};
...
Angular Zone
...
externalLib.request()
then(() => {
this.ngZone.run(
() => {
// Inside zone
}
);
});
window.onresize = () => {
this.ngZone.run(
() => {
// Inside zone
}
);
};
Angular Zone
...
this.ngZone
.runOutsideAngular(() => {
// Circument Angular CD
});
Outside
Angular Zone
...
this.ngZone
.runOutsideAngular(() => {
// Circument Angular CD
setInterval(() => {
// No CD triggers
}, 1000);
});
Outside
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
->
->
UI
UI
UI
UI
UI
UI
UI
UI
UI
UI
UI
UI
UI
Page
Template
UI
UI
UI
UI
UI
UI
UI
UI
Smart
Smart
Page
Template
UI
UI
UI
UI
Smart
Smart
Page
Template
Data
Events
Service
Router
UI
UI
Feature
Page
Template
Data
Events
Service
Router
UI
Pagination (dumb)
Pagination (smart)
Page
Template
Data
Events
Route
ActivatedRoute
UI
UI
UI
UI
Smart
Smart
Page
Template
Service
Service
UI
UI
UI
UI
Smart
Smart
Page
Template
Store
UI
UI
UI
UI
Smart
Smart
Page
Template
Apollo
UI
UI
UI
UI
UI
UI
UI
UI
Page
Template
products-list
Page
Template
Page
Template
products-list
products-list
Page
Template
products-list
products-list
Service
Api
ProductUnion[]
ApiProduct[]
Page
Template
products-list
products-list
Service
Api
ProductUnion[]
ApiProduct[]
UI
UI
UI
UI
Page
Template
products-list
products-list
Service
Api
ProductUnion[]
ApiProduct[]
UI
UI
UI
UI
App
@Injectable({
providedIn: 'root',
})
export class ProductService {
products$: Observable<ApiProduct[]>;
private productsSubject = new Subject<ApiProduct[]>();
constructor(private httpClient: HttpClient) {
this.products$ = this.productsSubject.asObservable();
}
get() {
this.httpClient.get('/products')
.subscribe(
products => this.productsSubject.next(products),
error => console.error(error)
);
}
}
products$!: Observable<ProductUnion[]>;
constructor(
private productService: ProductService
) {}
ngOnInit(): void {
this.products$ = this.productService.products$
.pipe(
map(apiProductsToProductUnions)
);
}
ngAfterViewInit(): void {
this.productService.get();
}
const apiProductsToProductUnions = (
apiProducts: ApiProduct[]
): ProductUnion[] =>
apiProducts.map(apiProductToProductUntion);
const apiProductToProductUntion = (
{ category, ...apiProduct }: ApiProduct
): ProductDefault => ({
...apiProduct,
type: 'product',
subtitle: category,
isLimited:
apiProduct.quantity.max / apiProduct.quantity.step < 6
});
export interface ProductDefault extends Product {
type: 'product';
quantity: Quantity;
isLimited: boolean;
}
export interface ProductDefault extends Product {
type: 'product';
quantity: Quantity;
isLimited: boolean;
cartInfo?: CartInfo;
}
interface CartInfo {
quantity: number;
total: number;
}
Page
Template
products-list
products-list
Product
Api
ProductUnion[]
ApiProduct[]
Page
Template
products-list
products-list
Product
ProductUnion[]
ApiProduct[]
Cart
Cart
Api
SessionStorage
products$!: Observable<ProductUnion[]>;
constructor(
private productService: ProductService,
private cartService: CartService
) {}
ngOnInit(): void {
this.products$ = combineLatest(
[
this.productService.products$
.pipe(map(apiProductsToProductUnions)),
this.cartService.cart$,
],
combineProductsAndCart
);
}
ngAfterViewInit(): void {
this.productService.get();
}
const combineProductsAndCart = (
products: ProductUnion[],
cart: Cart
): ProductUnion[] =>
products.map((product: ProductUnion) => {
if (product.type === 'product') {
const cartProduct = cart.products.find(
({ id }) => product.id === id
);
product.cartInfo = cartProduct;
}
return product;
});
const combineProductsAndCart = (
products: ProductUnion[],
cart: Cart
): ProductUnion[] =>
products.map((product: ProductUnion) => {
if (product.type === 'product') {
product.cartInfo = cart.products.find(
({ id }) => product.id === id
);
}
return product;
});
const combineProductsAndCart = (
products: ProductUnion[],
cart: Cart
): ProductUnion[] =>
// O(n)
products.map((product: ProductUnion) => {
if (product.type === 'product') {
product.cartInfo = cart.products.find(
({ id }) => product.id === id
);
}
return product;
});
const combineProductsAndCart = (
products: ProductUnion[],
cart: Cart
): ProductUnion[] =>
// O(n)
products.map((product: ProductUnion) => {
if (product.type === 'product') {
// O(m)
product.cartInfo = cart.products.find(
({ id }) => product.id === id
);
}
return product;
});
// O(n * m)
const combineProductsAndCart = (
products: ProductUnion[],
cart: Cart
): ProductUnion[] =>
// O(n)
products.map((product: ProductUnion) => {
if (product.type === 'product') {
// O(m)
product.cartInfo = cart.products.find(
({ id }) => product.id === id
);
}
return product;
});
this.products$ = combineLatest(
[
this.productsSubject
.pipe(map(apiProductsToProductUnions)),
cartService.cart$,
],
combineProductsAndCart
);
const products = [
{
name: 'T-shirt',
// ...
},
{
name: 'Pants',
// ...
},
{
name: 'Hat',
// ...
}
];
// O(n)
const hat = products.find(
({name}) => name === 'Hat'
);
const products = {
'T-shirt': {
name: 'T-shirt'
// ...
},
'Pants': {
name: 'Pants'
// ...
},
'Hat': {
name: 'Hat'
// ...
}
};
// O(1)
const hat = products['Hat'];
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
interface Product {
id: string;
// ...
}
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
interface Product {
id: string;
// ...
}
const products: Normalized<Product> = {
byId: {
'2443': {
name: 'T-shirt',
// ...
},
'5444': {
name: 'Pants' ,
// ...
},
// ...
},
allIds: ['2443', '5444', '...']
};
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
interface Product {
id: string;
// ...
}
const products: Normalized<Product> = {
byId: {
'2443': {
name: 'T-shirt',
// ...
},
// ...
},
allIds: ['2443', '...']
};
// O(1)
const product = products.byId['2443'];
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
// ...
const productsNormalized: Normalized<Product> = {
byId: {
'2443': {
name: 'T-shirt',
// ...
},
// ...
},
allIds: ['2443', '...']
};
// O(1)
const product = productsNormalized.byId['2443'];
// O(n)
const allProducts = products.allIds
.map(id => products.byId[id]);
helpers
// ...
interface Normalized<T extends Identifier> {
byId: { [id: string]: T };
allIds: string[];
}
const normalize = <T extends Identifier>(collection: T[]): Normalized<T> =>
collection.reduce(
({ allIds, byId }, t) => ({
allIds: [...allIds, t.id],
byId: {
...byId,
[t.id]: t,
},
}),
{
byId: {},
allIds: [],
} as Normalized<T>
);
const products = normalize([
// ...
]);
// O(1)
const product = products.byId['2443'];
helpers
// ...
interface Normalized<T extends Identifier> {
byId: { [id: string]: T };
allIds: string[];
}
const toArray = <T extends Identifier>({ byId, allIds }: Normalized<T>): T[] =>
allIds.map((id) => byId[id]);
// ...
const products = normalize([
// ...
]);
// O(n)
const allProducts = toArray(products);
interface AppState {
products: Normalized<Product>;
cart: {
products: Normalized<CartItem>;
total: number;
};
}
const state: AppState = {
products: {
byId: {
'1224': {
id: '1224',
// ...
},
// ...
},
allIds: ['1224', '...']
},
cart: {
products: {
byId: {
'1224': {
id: '1224',
quantity: 8,
total: 80,
// ...
}
}
},
total: 80
}
}
// O(n)
const combineProductsAndCart = (
{ products, cart }: AppState
): ProductUnion[] =>
// O(n)
toArray(products)
.map(product => {
return {
...product,
// O(1)
cartInfo: cart.products.byId[product.id]
};
});
// O(n)
const combineProductsAndCart = (
{ products, cart, deliveries }: AppState
): ProductUnion[] =>
// O(n)
toArray(products)
.map(product => {
return {
...product,
// O(1)
cartInfo: cart.products.byId[product.id],
// O(1)
delivery: deliveries.byId[product.id],
};
});
this.products$ = combineLatest(
[
this.productsSubject
.pipe(map(apiProductsToProductUnions)),
cartService.cart$,
],
combineProductsAndCart
);
interface Action<T extends String> {
type: T;
}
interface ActionWithPayload<T extends String, P>
extends Action<T> {
payload: P;
}
type AddToCart = ActionWithPayload<
'Add to Cart',
Pick<CartProduct, 'id' | 'quantity'>
>;
const addToCart = (payload: CartProduct): AddToCart => ({
type: 'Add to Cart',
payload
});
type RemoveFromCart = ActionWithPayload<'Remove from Cart', string>;
const removeFromCart = (payload: string): RemoveFromCart => ({
type: 'Remove from Cart',
payload,
});
type CartActions = AddToCart | RemoveFromCart;
Immutability can bring increased performance to your app, and leads to simpler programming and debugging, as data that never changes is easier to reason about than data that is free to be changed arbitrarily throughout your app.
Redux's use of shallow equality checking requires immutability if any connected components are to be updated correctly.
const collection = [];
collection.push('1');
const index = collection
.find(v => v === '1');
collection.splice(index, 1);
const getC = () => collection;
collection.map(
item => {
item.foo = '123';
return item;
}
);
const obj = {};
obj.name = 'Foo';
delete obj.name;
let collection = [];
collection = [...collection, '1'];
collection = collection
.filter(v => v !== '1');
const getC = () => collection.slice(0);
collection = collection.map(
item => ({
...item,
foo: '123'
})
);
let obj = {};
obj = { ...obj, name: 'foo' };
const { name, ...updateObj } = obj;
obj = updateObj;
interface AppState {
// ...
cart: {
products: Normalized<CartProduct>;
total: number;
};
}
const cartReducer = (state: AppState, action: CartAction): AppState => {
return state;
};
interface AppState {
// ...
cart: {
products: Normalized<CartProduct>;
total: number;
};
}
const cartReducer = (state: AppState, action: CartAction): AppState => {
if (action.type === 'Add to Cart') {
const { id, quantity } = action.payload;
const { products, cart } = state;
const total = products.byId[id].price * quantity;
return {
...state,
cart: {
products: {
byId: {
...cart.products.byId,
[id]: {
id,
quantity,
total,
},
},
allIds: [...cart.products.allIds, id],
},
total: cart.total + total,
},
};
}
if (action.type === 'Remove from Cart') {
const removeId = action.payload;
const { cart } = state;
const { [removeId]: removedProduct, ...byId } = cart.products.byId;
return {
...state,
cart: {
products: {
byId,
allIds: cart.products.allIds.filter((id) => id !== removeId),
},
total: cart.total - removedProduct.total,
},
};
}
return state;
};
Page
Template
products-list
products-list
Reducers
Store
AddToCartAction
UI
UI
UI
UI
state
selectQuantity
addToCart
addProductToCart
update
products
App
State management
UI
/**
* Sync approach (not optimized)
*/
// app-routing.module.ts
RouterModule.forRoot([]),
// page-products.module.ts
RouterModule.forChild([
{
path: 'products',
component PageProductsComponent
},
]),
/**
* Lazy load approach
*/
// app.module.ts
RouterModule.forRoot([
{
path: 'products',
loadChildren: () =>
import('./pages/page-products/page-products.module')
.then(
(m) => m.PageProductsModule
),
},
]),
// page-products.module.ts
RouterModule.forChild([
{
path: '',
component PageProductsComponent
},
]),
<ov-template-default>
<ng-template ovSide>
<h2>Side content</h2>
<router-outlet name="side"></router-outlet>
</ng-template>
<section main>
<h2>Main content</h2>
<router-outlet name="main"></router-outlet>
</section>
</ov-template-default>
RouterModule.forChild([
{
path: 'child-routes',
component: PageChildRoutesComponent,
children: [
{
path: '',
component: SmartProductsListComponent,
outlet: 'main',
},
{
path: '',
component: SideComponent,
outlet: 'side',
},
],
},
]),
<ul>
<!-- ... -->
<li>
<a [routerLink]="['child-routes']">
Child routes
</a>
</li>
</ul>
<ov-template-default>
<ng-template ovSide>
<h2>Side content</h2>
<router-outlet name="side"></router-outlet>
</ng-template>
<section main>
<h2>Main content</h2>
<router-outlet name="main"></router-outlet>
</section>
</ov-template-default>
RouterModule.forChild([
{
path: 'child-routes',
component: PageChildRoutesComponent,
children: [
// ...
{
path: 'flipped',
component: SideComponent,
outlet: 'main',
},
{
path: 'flipped',
component: SmartProductsListComponent,
outlet: 'side',
},
],
},
]),
<ul>
<!-- ... -->
<li>
<a
[routerLink]="[
'child-routes',
{
outlets: {
main: ['flipped'],
side: ['flipped']
}
}
]"
>Child routes flipped</a
>
</li>
</ul>
interface CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree>
| Promise<boolean | UrlTree>
| boolean | UrlTree
}
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) {}
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot
) {
if (!this.authService.isLoggedIn) {
return this.router
.createUrlTree(['/login']);
} else {
return true;
}
}
}
// Angular
interface Resolve<T> {
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<T> | Promise<T> | T
}
// product.resolver.ts
@Injectable({ providedIn: 'root' })
export class ProductResolver
implements Resolve<ProductState> {
constructor(private store: Store) {}
resolve(
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
): Observable<ProductState> {
return this.store.select(selectProductsState)
.pipe(
filter(({ loading }) => !loading),
take(1)
);
}
}
// page-products.module.ts
RouterModule.forChild([
{
path: 'resolved',
component: PageProductsComponent,
resolve: {
product: ProductResolver,
},
},
])
// page-products.component.ts
constructor(
private store: Store,
private route: ActivatedRoute
) {}
ngOnInit(): void {
const { product } = this.route.snapshot.data;
if (!product) {
this.store.dispatch(getProducts({ limit: 6 }));
}
}
constructor(private router: Router) {
const loadStart$ = this.router.events.pipe(
filter((event: Event) =>
event instanceof NavigationStart
)
);
const loadEnd$ = this.router.events.pipe(
filter((event: Event) =>
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError
)
);
loadStart$.pipe(
switchMap(() =>
merge(
loadEnd$.pipe(
timeout(200),
catchError(() => {
this.loading = true;
return NEVER;
})
),
loadEnd$.pipe(
timeout(3000),
catchError(() => {
this.loadingText =
"It's taking longer than expected!";
return NEVER;
})
),
loadEnd$
).pipe(
takeUntil(
loadEnd$.pipe(
observeOn(asapScheduler)
)
)
)
)
).subscribe(() => {
this.loading = false;
this.loadingText = '';
});
}
<ov-spinner
*ngIf="loading; else outlet"
[text]="loadingText">
</ov-spinner>
<ng-template #outlet>
<router-outlet></router-outlet>
</ng-template>
/**
* Thread blocking
*/
from(Array.from({ length: 10000000 }))
.subscribe(() => console.log('Compute'))
/**
* "it will do its best to minimize
* time between end of currently
* executing code and start of scheduled
* task"
*/
scheduled(
Array.from({ length: 10000000 }),
asapScheduler
).subscribe(() =>
console.log('Processing stuff')
);
/**
* "it schedules tasks asynchronously,
* by putting them on the JavaScript
* event loop queue."
*/
scheduled(
Array.from({ length: 10000000 }),
asyncScheduler
).subscribe(() =>
console.log('Processing stuff')
);
/**
* It makes sure scheduled task will
* happen just before next browser
* content repaint, thus performing
* animations as efficiently as possible.
*/
scheduled(
Array.from({ length: 10000000 }),
animationFrameScheduler
).subscribe(() =>
console.log('Processing stuff')
);
E2E
(UI)
Integration
Unit
Critical user flow testing
Render checks + Edge case testing
Isolated testing
Configures and initializes environment for unit testing and provides methods for creating components and services in unit tests.
TestBed is the primary api for writing unit tests for Angular applications and libraries.
@Component({
selector: 'app-banner',
template: '<h1>{{title}}</h1>'
})
export class BannerComponent {
title = 'Test Tour of Heroes';
}
describe('BannerComponent', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
});
it('should display original title', () => {
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain(component.title);
});
});
Module
Fixture
export declare class ComponentFixture<T> {
/**
* The instance of the root component class.
*/
componentInstance: T;
/**
* The native element at the root of the component.
*/
nativeElement: any;
/**
* The DebugElement associated with the root element of this component.
*/
debugElement: DebugElement;
// ...
}
@Component({
selector: 'app-banner',
template: '<h1>{{title}}</h1>'
})
export class BannerComponent {
title = 'Test Tour of Heroes';
}
describe('BannerComponent', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
});
it('should display original title', () => {
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain(component.title);
});
});
let component: ProductsComponent;
let fixture: ComponentFixture<ProductsComponent>;
const PRODUCTS_MOCK: Product[] = [...];
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ProductsComponent, MockComponent(ProductComponent)],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ProductsComponent);
component = fixture.componentInstance;
component.products = PRODUCTS_MOCK;
fixture.detectChanges();
});
it('should render 3 products', () => {
const productComponents = fixture.debugElement.queryAll(
By.directive(ProductComponent)
);
expect(productComponents.length).toBe(3);
productComponents.forEach(({ componentInstance }, index) => {
expect(componentInstance.product).toEqual(PRODUCTS_MOCK[index]);
});
});
Mock
Get mock instances
Assign mock data
Call change detection
Assert binding
Smart
Feature
Service access
Controls state
App specific
Dumb children
Deep test
Smart
Dumb
Dumb
Dumb
Dumb
Dumb
Piece of UI
No service access
No state
Portable/reusable
Dumb children
Shallow test
Service
Data
Data
Event
Event
Dumb
Dumb
Dumb
Data
Event
Event
Data
UI component tests
Smart
Dumb
Dumb
Data
Event
Event
Data
Service
Store
Smart component tests
Service
Service
Store
Atom
Molecule
Atom
Atom
UI
UI
Organism
Molecule
Smart
Smart
Page
Template
feature
feature