Understanding Data Passing in Angular Using Router and Component Binding (ComponentInputBinding)

Understanding Data Passing in Angular Using Router and Component Binding (ComponentInputBinding)

In this article, I'll show how to extract parameters and data from Angular routes using the component input binding feature.

Setup

If unsure how to set up Angular with Jest please refer to the article: barcioch.pro/angular-with-jest-setup.

Component Input Binding

Enable it

There are two ways of enabling this feature.

  1. import RouterModule.forRoot and pass the option bindToComponentInputs: true and paramsInheritanceStrategy: 'always'
imports: [
  ...
  RouterModule.forRoot(routes, {bindToComponentInputs: true, paramsInheritanceStrategy: 'always'})
]
  1. Use provider function provideRouter and pass the withComponentInputBinding() and withRouterConfig({paramsInheritanceStrategy: 'always'}) features
providers: [
  ...
  provideRouter(routes, withComponentInputBinding(), withRouterConfig({paramsInheritanceStrategy: 'always'}))
]

Use it

To use this feature define component inputs using the well-known @Input decorator.

@Component({
  [...]
})
export class TestComponent {
  @Input() testId?: string;
  @Input() sort?: string;
  @Input() secretData?: string;
}

Also, this component has to be routed. That means, one of the application routes has to point directly to it.

export const routes: Routes = [
  {
    path: 'test/:testId',
    component: TestComponent,
    data: {secretData: 'secret'}
  },
];

Now, path params, query params and data will be assigned to the inputs by matching their names. If the name collision occurs the route parameters are assigned in the following order: data > path param > query param. Also, they won't override each other, i.e: you cannot override path param with query param and so on.

Test it

Routes

import { Routes } from '@angular/router';
import { RoutedComponentComponent } from '../routed-component/routed-component.component';


export const routes: Routes = [
  {
    path: 'params/:pathParam',
    component: RoutedComponentComponent,
    data: {dataParam: 'secret'}
  },
];

Test components

I'll be using two components. Their purpose is to test, that route information is bound only in the the routed components. The non-routed behave as before - you have to pass parameters explicitly.

The routed one:

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-routed-component',
  template: `
    <span data-test="routed-pathParam">{{ pathParam }}</span>
    <span data-test="routed-queryParam">{{ queryParam }}</span>
    <span data-test="routed-nonExistingParam">{{ nonExistingParam }}</span>
    <span data-test="routed-dataParam">{{ dataParam }}</span>

    <app-non-routed-component></app-non-routed-component>
  `
})
export class RoutedComponentComponent {
  @Input() pathParam?: string;
  @Input() nonExistingParam?: string;
  @Input() queryParam?: string;
  @Input() dataParam?: string;
}

and non-routed:

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-non-routed-component',
  template: `
    <span data-test="non-routed-pathParam">{{ pathParam }}</span>
    <span data-test="non-routed-queryParam">{{ queryParam }}</span>
    <span data-test="non-routed-nonExistingParam">{{ nonExistingParam }}</span>
    <span data-test="non-routed-dataParam">{{ dataParam }}</span>
  `
})
export class NonRoutedComponentComponent {
  @Input() pathParam?: string;
  @Input() nonExistingParam?: string;
  @Input() queryParam?: string;
  @Input() dataParam?: string;
}

Test suite

The test suite employs RouterTestingHarness to reduce the boilerplate code. The testing procedure is simple: navigate through the routes and verify the bound information from routed and non-routed components. Also, test the name collision.

import { provideRouter, withComponentInputBinding, withRouterConfig } from '@angular/router';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { routes } from './app-routing';
import { RouterTestingHarness } from '@angular/router/testing';
import { RoutedComponentComponent } from '../routed-component/routed-component.component';
import { NonRoutedComponentComponent } from '../non-routed-component/non-routed-component.component';


describe('ComponentInputBinding', () => {
  let harness: RouterTestingHarness;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      declarations: [RoutedComponentComponent, NonRoutedComponentComponent],
      providers: [provideRouter(routes, withComponentInputBinding(), withRouterConfig({ paramsInheritanceStrategy: 'always' }))],
    });

    harness = await RouterTestingHarness.create();
  });

  describe('Test standard route params', () => {
    describe('when user enters /params/123?queryParam=paramFromQuery url', () => {
      beforeEach(async () => {
        await harness.navigateByUrl('/params/123?queryParam=paramFromQuery');
        harness.fixture.detectChanges();
      });

      it('should display routed component params', () => {
        const expected: ComponentParams = {
          pathParam: '123',
          queryParam: 'paramFromQuery',
          nonExistingParam: '',
          dataParam: 'secret',
        };
        expect(getRoutedComponentParams()).toEqual(expected);
      });

      it('should display empty input values in non-routed component', () => {
        const expected: ComponentParams = {
          pathParam: '',
          queryParam: '',
          nonExistingParam: '',
          dataParam: '',
        };
        expect(getNonRoutedComponentParams()).toEqual(expected);
      });

      describe('and user navigates to same route with different pathParam and queryParam (/params/456?queryParam=aNewValue)', () => {
        beforeEach(async () => {
          await harness.navigateByUrl('/params/456?queryParam=aNewValue');
          harness.fixture.detectChanges();
        });

        it('should display routed component params', () => {
          const expected: ComponentParams = {
            pathParam: '456',
            queryParam: 'aNewValue',
            nonExistingParam: '',
            dataParam: 'secret',
          };
          expect(getRoutedComponentParams()).toEqual(expected);
        });

        it('should display empty input values in non-routed component', () => {
          const expected: ComponentParams = {
            pathParam: '',
            queryParam: '',
            nonExistingParam: '',
            dataParam: '',
          };
          expect(getNonRoutedComponentParams()).toEqual(expected);
        });
      });
    });
  });

  describe('Test name collision', () => {
    describe('when user enters url with query params named same as pathParam and dataParam (/params/123?pathParam=pathParamFromQuery&dataParam=dataParamFromQuery)', () => {
      beforeEach(async () => {
        await harness.navigateByUrl('/params/123?pathParam=pathParamFromQuery&dataParam=dataParamFromQuery');
        harness.fixture.detectChanges();
      });

      it('should not override path params and data', () => {
        const expected: ComponentParams = {
          pathParam: '123',
          queryParam: '',
          nonExistingParam: '',
          dataParam: 'secret',
        };
        expect(getRoutedComponentParams()).toEqual(expected);
      });

      it('should display empty input values in non-routed component', () => {
        const expected: ComponentParams = {
          pathParam: '',
          queryParam: '',
          nonExistingParam: '',
          dataParam: '',
        };
        expect(getNonRoutedComponentParams()).toEqual(expected);
      });
    });
  });

  const getRoutedComponentParams = (): ComponentParams => {
    return {
      pathParam: harness.fixture.debugElement.query(By.css('[data-test="routed-pathParam"]')).nativeElement.textContent,
      queryParam: harness.fixture.debugElement.query(By.css('[data-test="routed-queryParam"]')).nativeElement.textContent,
      nonExistingParam: harness.fixture.debugElement.query(By.css('[data-test="routed-nonExistingParam"]')).nativeElement.textContent,
      dataParam: harness.fixture.debugElement.query(By.css('[data-test="routed-dataParam"]')).nativeElement.textContent,
    }
  }

  const getNonRoutedComponentParams = (): ComponentParams => {
    return {
      pathParam: harness.fixture.debugElement.query(By.css('[data-test="non-routed-pathParam"]')).nativeElement.textContent,
      queryParam: harness.fixture.debugElement.query(By.css('[data-test="non-routed-queryParam"]')).nativeElement.textContent,
      nonExistingParam: harness.fixture.debugElement.query(By.css('[data-test="non-routed-nonExistingParam"]')).nativeElement.textContent,
      dataParam: harness.fixture.debugElement.query(By.css('[data-test="non-routed-dataParam"]')).nativeElement.textContent,
    }
  }
});

interface ComponentParams {
  pathParam: string;
  queryParam: string;
  nonExistingParam: string;
  dataParam: string;
}

Source code

gitlab.com/barcioch-blog-examples/014-angul..