File

src/lib/route.ts

Description

A stub for ActivatedRoute. It behaves almost the same way as the actual ActivatedRoute, exposing a snapshot and observables for the params, query params etc., which are kept in sync.

In addition, this stub allows simulating a navigation by changing the params, the query params, the fragment, etc. When that happens, the snapshot is modified, then the relevant observables emit the new values.

There are some things that don't really work the same way as the real ActivatedRoute though:

  • the handling of the firstChild and of the children is entirely under the tester's responsibility. Setting the parent of a route stub does not add this route to the children of its parent, for example.
  • when changing the params, query params, fragment, etc., their associated observable emits unconditionally, instead of first checking if the value is actually different from before. It's thus the responsibility of the tester to not change the values if they're the same as before.
  • the params, paramMap, queryParams and queryParamMap objects of the route snapshot change when params or query params are set on the stub route. So if the code keeps a reference to params or paramMaps, it won't see the changes.

Extends

ActivatedRoute

Index

Methods
Accessors

Constructor

constructor(options?: ActivatedRouteStubOptions)

Constructs a new instance, based on the given options. If an option is not provided (or if no option is provided at all), then the route has a default value for this option (empty parameters for example, null fragment, etc.) If no parent is passed, then this route has no parent and is thus set as the root. Otherwise, the root and the path from root are created based on the root and path from root of the given parent route.

Parameters :
Name Type Optional
options ActivatedRouteStubOptions Yes

Methods

Public setData
setData(data: Data)

Triggers a navigation with the given new data. The other parameters, as well as all the other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change the data.

Parameters :
Name Type Optional
data Data No
Returns : void
Public setDataItem
setDataItem(name: string, value)

Triggers a navigation with the given new data item. The other data, as well as all the other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change one data item.

Parameters :
Name Type Optional
name string No
value No
Returns : void
Public setFragment
setFragment(fragment: string | null)

Triggers a navigation with the given new fragment. The other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change the fragment.

Parameters :
Name Type Optional
fragment string | null No
Returns : void
Public setParam
setParam(name: string, value: string)

Triggers a navigation with the given new parameter. The other parameters, as well as all the other parts (query params etc.) stay as they are. This is a shortcut to triggerNavigation that can be used to only change one parameter.

Parameters :
Name Type Optional
name string No
value string No
Returns : void
Public setParams
setParams(params: Params)

Triggers a navigation with the given new parameters. All the other parts (query params etc.) stay as they are. This is a shortcut to triggerNavigation that can be used to only change the parameters.

Parameters :
Name Type Optional
params Params No
Returns : void
Public setQueryParam
setQueryParam(name: string, value: string)

Triggers a navigation with the given new parameter. The other query parameters, as well as all the other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change one query parameter.

Parameters :
Name Type Optional
name string No
value string No
Returns : void
Public setQueryParams
setQueryParams(queryParams: Params)

Triggers a navigation with the given new query parameters. All the other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change the query parameters.

Parameters :
Name Type Optional
queryParams Params No
Returns : void
Public setTitle
setTitle(title: string | undefined)

Triggers a navigation with the given new title. The other parameters, as well as all the other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change the title.

Parameters :
Name Type Optional
title string | undefined No
Returns : void
Public setUrl
setUrl(url: Array)

Triggers a navigation with the given new url. The other parts (params etc.) stay as the are. This is a shortcut to triggerNavigation that can be used to only change the url.

Parameters :
Name Type Optional
url Array<UrlSegment> No
Returns : void
Public toString
toString()
Returns : string
Public triggerNavigation
triggerNavigation(options: literal type)

Triggers a navigation based on the given options. If an option is undefined or null, it's ignored. Except for fragment, which is only ignored if it's undefined, because null is a valid value for a fragment.

The non-ignored values are used to change the snapshot of the route. Once the snapshot has been modified, the observables corresponding to the updated parts emit the new value.

So, setting params and query params will make the params and queryParams observables emit, but not the fragment, data and url observables for example. This is consistent to how the router behaves.

Note: since the title of a route can become undefined, in order to be able to distinguish between a navigation which leaves the title as it is and a navigation that sets the title to undefined, a wrapper object is used for the title. So

  • triggerNavigation({ params:... }) leaves the title as is because it's undefined in the options
  • triggerNavigation({ title: { value: 'test' } }) sets the title to 'test'
  • triggerNavigation({ title: { value: undefined } }) sets the title to undefined
Parameters :
Name Type Optional
options literal type No
Returns : void

Accessors

root
getroot()
pathFromRoot
getpathFromRoot()
firstChild
getfirstChild()
children
getchildren()
routeConfig
getrouteConfig()
import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap, Data, ParamMap, Params, Route, UrlSegment } from '@angular/router';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { Type } from '@angular/core';

/**
 * Creates a fake partial ActivatedRoute for tests.
 *
 * If you pass params, then the created route's paramMap will contain the same values.
 * The same goes for queryParams and queryParamMap.
 *
 * If you pass a parent route and a snapshot, and the passed snapshot doesn't have a parent, then the snapshot's
 * parent will be set to the parent route snapshot. This allows the code under test to use
 * `route.parent.snapshot` or `route.snapshot.parent`.
 *
 * If you pass a snapshot with a parent, but don't pass a parent or pass a parent without snapshot, then the route's
 * parent snapshot will be set to the given snapshot's parent. This allows the code under test to use
 * `route.parent.snapshot` or `route.snapshot.parent`.
 *
 * @returns a partially populated, fake ActivatedRoute, depending on what you passed in
 * @deprecated favor stubRoute, which creates an easier to use and more logical stub
 */
export function fakeRoute(options: {
  url?: Observable<UrlSegment[]>;
  /** An observable of the matrix parameters scoped to this route */
  params?: Observable<Params>;
  /** An observable of the query parameters shared by all the routes */
  queryParams?: Observable<Params>;
  /** An observable of the URL fragment shared by all the routes */
  fragment?: Observable<string>;
  /** An observable of the static and resolved data of this route. */
  data?: Observable<Data>;
  /** The outlet name of the route. It's a constant */
  outlet?: string;
  /** The component of the route. It's a constant */
  component?: Type<unknown> | string | null;
  /** The current snapshot of this route */
  snapshot?: ActivatedRouteSnapshot;
  /** The configuration used to match this route */
  routeConfig?: Route | null;
  /** The root of the router state */
  root?: ActivatedRoute;
  /** The parent of this route in the router state tree */
  parent?: ActivatedRoute | null;
  /** The first child of this route in the router state tree */
  firstChild?: ActivatedRoute;
  /** The children of this route in the router state tree */
  children?: ActivatedRoute[];
  /** The path from the root of the router state tree to this route */
  pathFromRoot?: ActivatedRoute[];
}): ActivatedRoute {
  const result = {
    url: options.url,
    params: options.params,
    paramMap: options.params && options.params.pipe(map(params => convertToParamMap(params))),
    queryParams: options.queryParams,
    queryParamMap: options.queryParams && options.queryParams.pipe(map(params => convertToParamMap(params))),
    fragment: options.fragment,
    data: options.data,
    outlet: options.outlet,
    component: options.component,
    snapshot: options.snapshot,
    routeConfig: options.routeConfig,
    root: options.root,
    parent: options.parent,
    firstChild: options.firstChild,
    children: options.children,
    pathFromRoot: options.pathFromRoot
  } as ActivatedRoute;

  for (let route: null | ActivatedRoute = result; route; route = route.parent) {
    if (route.parent && route.parent.snapshot && !route.snapshot) {
      // eslint-disable-next-line deprecation/deprecation
      route.snapshot = fakeSnapshot({});
    }
    if (route.parent && route.parent.snapshot && !route.snapshot.parent) {
      (route.snapshot as Omit<ActivatedRouteSnapshot, 'parent'> & { parent: ActivatedRouteSnapshot }).parent = route.parent.snapshot;
    }

    if (route.snapshot && route.snapshot.parent && !route.parent) {
      // eslint-disable-next-line deprecation/deprecation
      (route as Omit<ActivatedRoute, 'parent'> & { parent: ActivatedRoute }).parent = fakeRoute({});
    }
    if (route.snapshot && route.snapshot.parent && route.parent && !route.parent.snapshot) {
      route.parent.snapshot = route.snapshot.parent;
    }
  }

  return result;
}

/**
 * Creates a fake partial ActivatedRouteSnapshot for tests.
 *
 * If you pass params, then the created snapshot's paramMap will contain the same values.
 * The same goes for queryParams and queryParamMap.
 *
 * @returns a partially populated, fake ActivatedRoute, depending on what you passed in
 * @deprecated favor stubRoute, which creates an easier to use and more logical stub for both the route and its snapshot
 */
export function fakeSnapshot(options: {
  url?: UrlSegment[];
  /** The matrix parameters scoped to this route */
  params?: Params;
  /** The query parameters shared by all the routes */
  queryParams?: Params;
  /** The URL fragment shared by all the routes */
  fragment?: string;
  /** The static and resolved data of this route */
  data?: Data;
  /** The outlet name of the route */
  outlet?: string;
  /** The component of the route */
  component?: Type<unknown> | string | null;
  /** The configuration used to match this route */
  routeConfig?: Route;
  /** The root of the router state */
  root?: ActivatedRouteSnapshot;
  /** The parent of this route in the router state tree */
  parent?: ActivatedRouteSnapshot | null;
  /** The first child of this route in the router state tree */
  firstChild?: ActivatedRouteSnapshot | null;
  /** The children of this route in the router state tree */
  children?: ActivatedRouteSnapshot[];
  /** The path from the root of the router state tree to this route */
  pathFromRoot?: ActivatedRouteSnapshot[];
}): ActivatedRouteSnapshot {
  return {
    url: options.url,
    params: options.params,
    paramMap: options.params && convertToParamMap(options.params),
    queryParams: options.queryParams,
    queryParamMap: options.queryParams && convertToParamMap(options.queryParams),
    fragment: options.fragment,
    data: options.data,
    outlet: options.outlet,
    component: options.component,
    routeConfig: options.routeConfig,
    root: options.root,
    parent: options.parent,
    firstChild: options.firstChild,
    children: options.children,
    pathFromRoot: options.pathFromRoot
  } as ActivatedRouteSnapshot;
}

/**
 * The options that are passed when creating an ActivatedRouteStub.
 */
export interface ActivatedRouteStubOptions {
  /**
   * The initial values of the parameters of the route
   */
  params?: Params;
  /**
   * The initial values of the query parameters of the route
   */
  queryParams?: Params;
  /**
   * The initial values of the data of the route
   */
  data?: Data;
  /**
   * The initial values of the title of the route
   */
  title?: string;
  /**
   * The initial fragment of the route
   */
  fragment?: string | null;
  /**
   * The initial url of the route
   */
  url?: UrlSegment[];
  /**
   * The parent of the route
   */
  parent?: ActivatedRouteStub | null;
  /**
   * The first child of the route
   */
  firstChild?: ActivatedRouteStub | null;
  /**
   * The children of the route
   */
  children?: ActivatedRouteStub[] | null;
  /**
   * The configuration of the route
   */
  routeConfig?: Route | null;
}

class ActivatedRouteSnapshotStub extends ActivatedRouteSnapshot {
  private _parent: ActivatedRouteSnapshot | null = null;
  private _root: ActivatedRouteSnapshot;
  private _firstChild: ActivatedRouteSnapshot | null = null;
  private _children: Array<ActivatedRouteSnapshot> = [];
  private _pathFromRoot: Array<ActivatedRouteSnapshot> = [];
  private _title: string | undefined;

  get parent(): ActivatedRouteSnapshot | null {
    return this._parent;
  }

  set parent(value: ActivatedRouteSnapshot | null) {
    this._parent = value;
  }

  get root(): ActivatedRouteSnapshot {
    return this._root;
  }

  set root(value: ActivatedRouteSnapshot) {
    this._root = value;
  }

  get firstChild(): ActivatedRouteSnapshot | null {
    return this._firstChild;
  }

  set firstChild(value: ActivatedRouteSnapshot | null) {
    this._firstChild = value;
  }

  get children(): Array<ActivatedRouteSnapshot> {
    return this._children;
  }

  set children(value: Array<ActivatedRouteSnapshot>) {
    this._children = value;
  }

  get pathFromRoot(): Array<ActivatedRouteSnapshot> {
    return this._pathFromRoot;
  }

  set pathFromRoot(value: Array<ActivatedRouteSnapshot>) {
    this._pathFromRoot = value;
  }

  get title(): string | undefined {
    return this._title;
  }

  set title(value: string | undefined) {
    this._title = value;
  }

  get paramMap(): ParamMap {
    return convertToParamMap(this.params);
  }

  get queryParamMap(): ParamMap {
    return convertToParamMap(this.queryParams);
  }

  constructor() {
    super();
    this._root = this;
  }
}

/**
 * A stub for ActivatedRoute. It behaves almost the same way as the actual ActivatedRoute, exposing a snapshot
 * and observables for the params, query params etc., which are kept in sync.
 *
 * In addition, this stub allows simulating a navigation by changing the params, the query params, the fragment, etc.
 * When that happens, the snapshot is modified, then the relevant observables emit the new values.
 *
 * There are some things that don't really work the same way as the real ActivatedRoute though:
 * - the handling of the firstChild and of the children is entirely under the tester's responsibility. Setting the parent
 *   of a route stub does not add this route to the children of its parent, for example.
 * - when changing the params, query params, fragment, etc., their associated observable emits unconditionally, instead of
 *   first checking if the value is actually different from before. It's thus the responsibility of the tester to not
 *   change the values if they're the same as before.
 * - the params, paramMap, queryParams and queryParamMap objects of the route snapshot change when params or query params are set
 *   on the stub route. So if the code keeps a reference to params or paramMaps, it won't see the changes.
 */
export class ActivatedRouteStub extends ActivatedRoute {
  private _firstChild: ActivatedRouteStub | null;
  private _children: Array<ActivatedRouteStub>;

  private readonly paramsSubject: BehaviorSubject<Params>;
  private readonly queryParamsSubject: BehaviorSubject<Params>;
  private readonly dataSubject: BehaviorSubject<Data>;
  private readonly fragmentSubject: BehaviorSubject<string | null>;
  private readonly urlSubject: BehaviorSubject<Array<UrlSegment>>;
  private readonly titleSubject: BehaviorSubject<string | undefined>;

  private _parent: ActivatedRouteStub | null;
  private _root: ActivatedRouteStub;
  private _pathFromRoot: Array<ActivatedRouteStub>;

  /**
   * Constructs a new instance, based on the given options.
   * If an option is not provided (or if no option is provided at all), then the route has a default value for this option
   * (empty parameters for example, null fragment, etc.)
   * If no parent is passed, then this route has no parent and is thus set as the root. Otherwise, the root and the path
   * from root are created based on the root and path from root of the given parent route.
   */
  constructor(options?: ActivatedRouteStubOptions) {
    super();

    const snapshot = new ActivatedRouteSnapshotStub();
    this.snapshot = snapshot;

    this._firstChild = options?.firstChild ?? null;
    this._children = options?.children ?? [];
    this._parent = options?.parent ?? null;
    this._root = this.parent?.root ?? this;
    this._pathFromRoot = this.parent ? [...this.parent.pathFromRoot, this] : [this];

    snapshot.params = options?.params ?? {};
    snapshot.queryParams = options?.queryParams ?? {};
    snapshot.data = options?.data ?? {};
    snapshot.title = options?.title;
    snapshot.fragment = options?.fragment ?? null;
    snapshot.url = options?.url ?? [];
    // @ts-expect-error the routeConfig is readonly, but we need to overwrite it here
    snapshot.routeConfig = options?.routeConfig ?? null;

    snapshot.firstChild = this.firstChild?.snapshot ?? null;
    snapshot.children = this.children?.map(route => route.snapshot) ?? [];
    snapshot.parent = this.parent?.snapshot ?? null;
    snapshot.root = this.root.snapshot;
    snapshot.pathFromRoot = this.pathFromRoot.map(route => route.snapshot);

    this.paramsSubject = new BehaviorSubject<Params>(this.snapshot.params);
    this.queryParamsSubject = new BehaviorSubject<Params>(this.snapshot.queryParams);
    this.dataSubject = new BehaviorSubject<Data>(this.snapshot.data);
    this.titleSubject = new BehaviorSubject<string | undefined>(this.snapshot.title);
    this.fragmentSubject = new BehaviorSubject<string | null>(this.snapshot.fragment);
    this.urlSubject = new BehaviorSubject<Array<UrlSegment>>(this.snapshot.url);

    this.params = this.paramsSubject.asObservable();
    this.queryParams = this.queryParamsSubject.asObservable();
    this.data = this.dataSubject.asObservable();
    this.fragment = this.fragmentSubject.asObservable();
    this.url = this.urlSubject.asObservable();
    // @ts-expect-error the title is readonly, but we need to be able to initialize it  here
    this.title = this.titleSubject.asObservable();
  }

  get root() {
    return this._root;
  }

  get parent(): ActivatedRouteStub | null {
    return this._parent;
  }

  get pathFromRoot(): Array<ActivatedRouteStub> {
    return this._pathFromRoot;
  }

  get firstChild(): ActivatedRouteStub | null {
    return this._firstChild;
  }

  get children(): Array<ActivatedRouteStub> {
    return this._children;
  }

  get routeConfig(): Route | null {
    return this.snapshot.routeConfig;
  }

  /**
   * Triggers a navigation with the given new parameters. All the other parts (query params etc.) stay as they are.
   * This is a shortcut to `triggerNavigation` that can be used to only change the parameters.
   */
  public setParams(params: Params): void {
    this.triggerNavigation({ params });
  }

  /**
   * Triggers a navigation with the given new parameter. The other parameters, as well as all the other parts (query params etc.)
   * stay as they are.
   * This is a shortcut to `triggerNavigation` that can be used to only change one parameter.
   */
  public setParam(name: string, value: string): void {
    this.setParams({ ...this.snapshot.params, [name]: value });
  }

  /**
   * Triggers a navigation with the given new query parameters. All the other parts (params etc.) stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change the query parameters.
   */
  public setQueryParams(queryParams: Params): void {
    this.triggerNavigation({ queryParams });
  }

  /**
   * Triggers a navigation with the given new parameter. The other query parameters, as well as all the other parts (params etc.)
   * stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change one query parameter.
   */
  public setQueryParam(name: string, value: string): void {
    this.setQueryParams({ ...this.snapshot.queryParams, [name]: value });
  }

  /**
   * Triggers a navigation with the given new data. The other parameters, as well as all the other parts (params etc.)
   * stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change the data.
   */
  public setData(data: Data): void {
    this.triggerNavigation({ data });
  }

  /**
   * Triggers a navigation with the given new data item. The other data, as well as all the other parts (params etc.)
   * stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change one data item.
   */
  public setDataItem(name: string, value: unknown): void {
    this.setData({ ...this.snapshot.data, [name]: value });
  }

  /**
   * Triggers a navigation with the given new title. The other parameters, as well as all the other parts (params etc.)
   * stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change the title.
   */
  public setTitle(title: string | undefined): void {
    this.triggerNavigation({ title: { value: title } });
  }

  /**
   * Triggers a navigation with the given new fragment. The other parts (params etc.)  stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change the fragment.
   */
  public setFragment(fragment: string | null): void {
    this.triggerNavigation({ fragment });
  }

  /**
   * Triggers a navigation with the given new url. The other parts (params etc.)  stay as the are.
   * This is a shortcut to `triggerNavigation` that can be used to only change the url.
   */
  public setUrl(url: Array<UrlSegment>): void {
    this.triggerNavigation({ url });
  }

  /**
   * Triggers a navigation based on the given options. If an option is undefined or null, it's ignored. Except for fragment, which is only
   * ignored if it's undefined, because null is a valid value for a fragment.
   *
   * The non-ignored values are used to change the snapshot of the route. Once the snapshot has been modified,
   * the observables corresponding to the updated parts emit the new value.
   *
   * So, setting params and query params will make the params and queryParams observables emit, but not the fragment, data and
   * url observables for example. This is consistent to how the router behaves.
   *
   * Note: since the title of a route can become undefined, in order to be able to distinguish between a navigation which leaves the title
   * as it is and a navigation that sets the title to undefined, a wrapper object is used for the title. So
   *
   * - `triggerNavigation({ params:... })` leaves the title as is because it's undefined in the options
   * - `triggerNavigation({ title: { value: 'test' } })` sets the title to 'test'
   * - `triggerNavigation({ title: { value: undefined } })` sets the title to undefined
   */
  public triggerNavigation(options: {
    params?: Params;
    queryParams?: Params;
    fragment?: string | null;
    data?: Data | null;
    title?: { value: string | undefined } | null;
    url?: Array<UrlSegment> | null;
  }): void {
    // set the snapshot first
    if (options.params) {
      this.snapshot.params = options.params;
    }
    if (options.queryParams) {
      this.snapshot.queryParams = options.queryParams;
    }
    if (options.fragment !== undefined) {
      this.snapshot.fragment = options.fragment;
    }
    if (options.data) {
      this.snapshot.data = options.data;
    }
    if (options.title) {
      // @ts-expect-error the title is readonly, but we need to be able to overwrite it here
      this.snapshot.title = options.title.value;
    }
    if (options.url) {
      this.snapshot.url = options.url;
    }

    // then emit everything that has changed
    if (options.params) {
      this.paramsSubject.next(this.snapshot.params);
    }
    if (options.queryParams) {
      this.queryParamsSubject.next(this.snapshot.queryParams);
    }
    if (options.fragment !== undefined) {
      this.fragmentSubject.next(this.snapshot.fragment);
    }
    if (options.data) {
      this.dataSubject.next(this.snapshot.data);
    }
    if (options.title) {
      this.titleSubject.next(this.snapshot.title);
    }
    if (options.url) {
      this.urlSubject.next(this.snapshot.url);
    }
  }

  public toString(): string {
    return 'ActivatedRouteStub';
  }
}

/**
 * Creates a new ActivatedRouteStub, by calling its constructor.
 */
export function stubRoute(options?: ActivatedRouteStubOptions): ActivatedRouteStub {
  return new ActivatedRouteStub(options);
}

results matching ""

    No results matching ""