Angular
Advanced
https://github.com/Rachnerd/ov-angular-advanced
Frontend development
App vs UI
UI development
Atomic Design
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/
Composition
Angular Material!
Thumbnail
Subtitle
Title
Rating
Price
Isolated component development
Component Isolation
"Build components without starting a complex dev setup, force certain data into your database, or navigate around your application"
Stories
- Each possible state of a component is put in a story.
- A story is supported by addons (a11y, device resolutions, performance, color blindness simulator etc)
- Component documentation
Portrait
landscape
Primary
Max
Min
Max
Min
Max
Min
Primary
Primary
Primary
Max
Min
Max
Min
Style | Layout
Product
- It should display product information
<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>
Product
- It should display product information
- It should display availability
<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>
Product
- It should display product information
- It should display availability
- It should warn about limited availability
<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>
Product
<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>
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
Product
<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>
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
- It could be completely replaced by an alternative product
Product
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
}
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
- It could be completely replaced by an alternative product
Product
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
}
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
- It could be completely replaced by an alternative product
Product
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
@Input() isOutOfStock = false;
}
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
- It could be completely replaced by an alternative product
Product
@Component({ ... })
export class ProductComponent {
@Input() product!: Product;
@Input() isLimited = false;
@Input() isOutOfStock = false;
@Input() isReplaced? = false;
@Input() replacement?: Product;
}
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
- It could be completely replaced by an alternative product
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>();
}
- It should display product information
- It should display availability
- It should warn about limited availability
- It could be out of stock and show a contact option
- It could be completely replaced by an alternative product
Composition
Slots
Content projection
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>
Content projection
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
?
Events + ng-content
Parent
ProductDefault
Product
event
event
<ov-product [product]="product">
<section actions>
<ov-button (click)="emitEvent()">
DoSomething
</ov-button>
</section>
<section footer>
...
</section>
</ov-product>
DEMO
Style | Layout
Template
Template
Template
Responsiveness
$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;
}
}
Content projection
conditional
- ng-container
- ng-template
- Directive
- @ContentChild(ren)
- ngAfterContentInit?
DEMO
Pipes
Pipes
- Used for transforming data
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'test',
})
export class TestPipe implements PipeTransform {
transform(value: unknown, ...args: unknown[]): unknown {
return null;
}
}
Pipes
- Used for transforming data
- Pure by default
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;
}
}
Pipes
- Used for transforming data
- Pure by default
- Built-in pipes:
- Currency
- Date
- Decimal
- Json
- LowerCase
- UpperCase
- Percent
- Slice
- Async
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;
}
}
QuantityOptions
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>
Demo
*Structural Directives
*Syntax sugar
<div *ngFor="let hero of heroes; let i=index; let odd=odd; trackBy: trackById"
[class.odd]="odd">
({{i}}) {{hero.name}}
</div>
*Syntax sugar
<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>
*ifBreakpoint
- It shows/hides templates based on breakpoint.
@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>
*ifBreakpoint
- It shows/hides templates based on breakpoint.
@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;
}
}
);
}
*ifBreakpoint
- It shows/hides templates based on breakpoint.
- It supports ranges of breakpoints
@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>
*ifBreakpoint
- It shows/hides templates based on breakpoint.
- It supports ranges of breakpoints
- It supports an "else" template if conditions are not met.
@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)
);
}
Demo
ChangeDetection
ChangeDetectionStrategy
Default
ChangeDetectionStrategy
OnPush
OnPush
OnPush
OnPush
ChangeDetectionStrategy
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
?
ChangeDetectionRef
@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);
}
}
ChangeDetectionStrategy
OnPush
OnPush
OnPush
OnPush
OnPush
OnPush
markForCheck()
ZoneJS
A Zone is an execution context that persists across async tasks. You can think of it as thread-local storage for JavaScript VMs.
Execution context
ZoneJS
Execution context
Angular Zone
Outside
externalLib.request()
then(() => {
// outside zone
});
window.onresize = () => {
// outside zone
};
...
Execution context
Angular Zone
...
externalLib.request()
then(() => {
this.ngZone.run(
() => {
// Inside zone
}
);
});
window.onresize = () => {
this.ngZone.run(
() => {
// Inside zone
}
);
};
Execution context
Angular Zone
...
this.ngZone
.runOutsideAngular(() => {
// Circument Angular CD
});
Outside
Execution context
Angular Zone
...
this.ngZone
.runOutsideAngular(() => {
// Circument Angular CD
setInterval(() => {
// No CD triggers
}, 1000);
});
Outside
JS Observers
IntersectionObserver
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
IntersectionObserver
->
Demo
ResizeObserver
->
Demo
App development
UI structures
UI
UI
UI
UI
UI
PageController
- Page uses template
- Page fills template slots with components
- Doesn't scale well
UI
UI
UI
UI
UI
UI
UI
UI
Page
Template
Smart components
- Page uses template
- Page fills template slots with components
- Smart components wrap ui and implement logic
- Scales well
UI
UI
UI
UI
UI
UI
UI
UI
Smart
Smart
Page
Template
Smart components
UI
UI
UI
UI
Smart
Smart
Page
Template
- Business logic
- State management
- Routing
- Analytics
- i18n
- ...
Data
Events
Service
Router
UI
UI
Feature
Page
Template
Data
Events
Service
Router
UI
Pagination (dumb)
Pagination (smart)
Page
Template
Data
Events
Route
ActivatedRoute
Decentralised state
UI
UI
UI
UI
Smart
Smart
Page
Template
Service
Service
Centralised state (Flux)
UI
UI
UI
UI
Smart
Smart
Page
Template
Store
Centralised state (GraphQL)
UI
UI
UI
UI
Smart
Smart
Page
Template
Apollo
State management
UI
UI
UI
UI
UI
UI
UI
UI
Page
Template
App state
UI state
App state
UI state
Integration
App state
UI state
- Fit-for-purpose
- Bottom up design
- Simple
- Optimised
- Based on backend data
- Business logic
Integration
- Pure functions
- Limits impact of:
- Redesigns
- Breaking apis
State management
Products-list
products-list
Page
Template
Smart Products-list
Page
Template
products-list
products-list
Smart 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
ProductService
@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)
);
}
}
SmartProductsList
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
});
Cart support
- Show "In cart" status
export interface ProductDefault extends Product {
type: 'product';
quantity: Quantity;
isLimited: boolean;
}
Cart support
- Show optional cart info
export interface ProductDefault extends Product {
type: 'product';
quantity: Quantity;
isLimited: boolean;
cartInfo?: CartInfo;
}
interface CartInfo {
quantity: number;
total: number;
}
Smart Products-list
Page
Template
products-list
products-list
Product
Api
ProductUnion[]
ApiProduct[]
Smart Products-list
Page
Template
products-list
products-list
Product
ProductUnion[]
ApiProduct[]
Cart
Cart
Api
SessionStorage
SmartProductsList
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;
});
Demo
Data structures
Big O
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;
});
Big O
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;
});
Big O
// 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;
});
Big O
this.products$ = combineLatest(
[
this.productsSubject
.pipe(map(apiProductsToProductUnions)),
cartService.cart$,
],
combineProductsAndCart
);
Data normalization
Collection
Normalized
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'];
TypeScript
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
TypeScript
helpers
interface Normalized<T extends { id: string }> {
byId: { [id: string]: T };
allIds: string[];
}
interface Product {
id: string;
// ...
}
TypeScript
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', '...']
};
TypeScript
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'];
TypeScript
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]);
TypeScript
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'];
TypeScript
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);
AppState
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
}
}
Normalized data
// O(n)
const combineProductsAndCart = (
{ products, cart }: AppState
): ProductUnion[] =>
// O(n)
toArray(products)
.map(product => {
return {
...product,
// O(1)
cartInfo: cart.products.byId[product.id]
};
});
Normalized data
// 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
);
Demo
Flux
Flux
Redux
Actions
- Type + Payload?
- Application triggers actions
- User triggers actions
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
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.
Immutability
Redux's use of shallow equality checking requires immutability if any connected components are to be updated correctly.
Mutable
Immutable
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;
Reducer
- Pure function
- Creates new state based on current state and action
interface AppState {
// ...
cart: {
products: Normalized<CartProduct>;
total: number;
};
}
const cartReducer = (state: AppState, action: CartAction): AppState => {
return state;
};
Reducer
- Pure function
- Creates new state based on current state and action
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;
};
Reducers with Immer.js
Redux
Page
Template
products-list
products-list
Reducers
Store
AddToCartAction
UI
UI
UI
UI
state
selectQuantity
addToCart
addProductToCart
update
products
App
State management
UI
Advanced Routing
Decentralize
- forRoot vs forChild
/**
* 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
},
]),
Child routes
- Configure slots via routing
<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>
Child routes
- Configure slots via routing
<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>
Demo
Guards
- Prevent loading of routes
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;
}
}
}
Resolvers
- Preload data
// 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 }));
}
}
Demo
Router events
- GuardsCheckEnd
- GuardsCheckStart
- NavigationCancel
- NavigationEnd
- NavigationError
- NavigationStart
- ResolveEnd
- ResolveStart
- RoutesRecognized
Loading state
- NavigationCancel
- NavigationEnd
- NavigationError
- NavigationStart
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>
RxJS
Schedulers
/**
* Thread blocking
*/
from(Array.from({ length: 10000000 }))
.subscribe(() => console.log('Compute'))
Schedulers
/**
* "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')
);
Schedulers
/**
* "it schedules tasks asynchronously,
* by putting them on the JavaScript
* event loop queue."
*/
scheduled(
Array.from({ length: 10000000 }),
asyncScheduler
).subscribe(() =>
console.log('Processing stuff')
);
Schedulers
/**
* 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')
);
Testing
Component testing
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.
What is TestBed?
TestBed example
@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
TestBed 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;
// ...
}
TestBed example
@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);
});
});
NgMocks
Products test
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
Demo utils
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
Smart vs Dumb
Shallow UI test
- Mock children
- Test UI based on input
- Test child event handlers
- Test child bindings
- Test event emitting
Dumb
Dumb
Dumb
Data
Event
Event
Data
DEMO
UI component tests
Shallow smart test
- Mock children
- Test UI based on input
- Test child event handlers
- Test child bindings
- Test event emitting
- Test service interaction
- Test state management
Smart
Dumb
Dumb
Data
Event
Event
Data
Service
Store
DEMO
Smart component tests
Service tests
- Mock dependencies
- "Regular" unit tests
- Marble testing
Service
Service
Store
DEMO
Marble testing
DEMO
NgRx tests
DEMO
Directive test
- Custom host
Live DEMO
Assignment UI
- Cart UI components
- Add "plus" and "minus" icon to IconComponent
- Fix Storybook + Unit tests
- Generate a CartProduct Molecule
- Module
- Component
- Storybook file
- Mock file
- Visualize the product using at least the thumbnail, title, quantity and add plus/minus icons + events
- Add "plus" and "minus" icon to IconComponent
- Create a CartProductsList
Assignment App
- Create a CartProductsList smart component.
- Put it in the side slot of the page.
- Connect it with NgRx by implementing a Cart selector
- Create Actions for the CartReducer for:
- Increase/decrease quantity
- Make it work.
- Bonus:
- Unit tests
- Remove from cart functionality
Atom
Molecule
Atom
Atom
UI
UI
Organism
Molecule
Smart
Smart
Page
Template
feature
feature
Angular Advanced
By rachnerd
Angular Advanced
- 233