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.
- import
RouterModule.forRoot
and pass the optionbindToComponentInputs: true
andparamsInheritanceStrategy: 'always'
imports: [
...
RouterModule.forRoot(routes, {bindToComponentInputs: true, paramsInheritanceStrategy: 'always'})
]
- Use provider function
provideRouter
and pass thewithComponentInputBinding()
andwithRouterConfig({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;
}