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

->

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