Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add RouterHistoryStore #302

Draft
wants to merge 25 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e39929b
feat: add `RouterHistoryStore`
LayZeeDK Dec 23, 2022
ac97fb9
refactor: add notes on `NavigationCancel` and `NavigationError` events
LayZeeDK Dec 24, 2022
0c28854
test: cover `RouterHistoryStore`
LayZeeDK Dec 24, 2022
61db5b1
refactor: refactor `RouterHistoryStore` to only store the navigation …
LayZeeDK Dec 26, 2022
017e222
test: cover `RouterHistoryStore` with more test cases
LayZeeDK Dec 26, 2022
f0b3924
fix: initialize `RouterHistoryStore` on app initialization
LayZeeDK Dec 27, 2022
61e229c
refactor: add notes on `NavigationSkipped`
LayZeeDK Jan 2, 2023
7febcb5
refactor: remove TODO and notes
LayZeeDK Jan 4, 2023
6198488
refactor: rename router sequences
LayZeeDK Jan 4, 2023
70fccc3
docs: explain history key
LayZeeDK Jan 4, 2023
84805a3
refactor: refactor `#maxCompletedNavigationId$`
LayZeeDK Jan 4, 2023
8370281
refactor: use router navigated observable connected to a single updater
LayZeeDK Jan 4, 2023
9c694e4
refactor: extract `PopstateNavigationStart` type and type guard
LayZeeDK Jan 4, 2023
521cde1
fix: handle `NavigationCancel` and `NavigationError`
LayZeeDK Jan 5, 2023
a2f5bac
refactor: improve type annotations and inline documentation
LayZeeDK Jan 5, 2023
fa809ff
refactor: remove unnecessary selector debouncing
LayZeeDK Jan 5, 2023
654119b
refactor: extract router sequence types and type guard
LayZeeDK Jan 5, 2023
16875ea
refactor: rename `RouterHistory`
LayZeeDK Jan 5, 2023
caefc58
refactor: rename `RouterHistoryStore##findSourceNavigatedSequence`
LayZeeDK Jan 5, 2023
da9f557
docs: describe `RouterHistory`
LayZeeDK Jan 5, 2023
de60779
feat: add navigation effects
LayZeeDK Jan 5, 2023
6508ce1
feat: provide `RouterHistoryStore` as `EnvironmentProviders`
LayZeeDK May 22, 2023
23295c3
refactor: rename `RouterHistoryStore#routerEvents` to `RouterHistoryS…
LayZeeDK May 22, 2023
b284f43
refactor: remove Component Store lifecycle support
LayZeeDK May 22, 2023
a3bdc1d
feat: add WIP `RouterHistoryStore#nextUrl$` property
LayZeeDK May 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NavigationStart } from '@angular/router';
import { NonNullish } from '../util-types/non-nullish';
import { Override } from '../util-types/override';

/**
* A `NavigationStart` event triggered by a `popstate` event.
*/
export type PopstateNavigationStart = Override<
NavigationStart,
{
navigationTrigger: 'popstate';
}
> &
NonNullish<Pick<NavigationStart, 'restoredState'>>;

export function isPopstateNavigationStart(
event: NavigationStart
): event is PopstateNavigationStart {
return event.navigationTrigger === 'popstate';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Event as NgRouterEvent,
NavigationEnd,
NavigationStart,
} from '@angular/router';

export const routerEvents: readonly NgRouterEvent[] = [
// 1. Navigate to the root path ‘/’, which redirects me to the homepage
// Current: Home
// Previous: None
// Next: None
new NavigationStart(1, '/', 'imperative', null),
new NavigationEnd(1, '/', '/home'),

// 2. Click a menu link to navigate to the About page
// Current: About
// Previous: Home
// Next: None
new NavigationStart(2, '/about', 'imperative', null),
new NavigationEnd(2, '/about', '/about'),

// 3. Click a menu link to navigate to the Company page
// Current: Company
// Previous About
// Next: None
new NavigationStart(3, '/company', 'imperative', null),
new NavigationEnd(3, '/company', '/company'),

// 4. Click the back button
// Current: About
// Previous: Home
// Next: Company
new NavigationStart(4, '/about', 'popstate', { navigationId: 2 }),
new NavigationEnd(4, '/about', '/about'),

// 5. Click a menu link to navigate to the Products page
// Current: Products
// Previous: About
// Next: None
new NavigationStart(5, '/products', 'imperative', null),
new NavigationEnd(5, '/products', '/products'),

// 6. Click a menu link to navigate to the Home page
// Current: Home
// Previous: Products
// Next: None
new NavigationStart(6, '/home', 'imperative', null),
new NavigationEnd(6, '/home', '/home'),

// 7. Click a menu link to navigate to the About page
// Current: About
// Previous: Home
// Next: None
new NavigationStart(7, '/about', 'imperative', null),
new NavigationEnd(7, '/about', '/about'),

// 8. Click the back button
// Current: Home
// Previous: Products
// Next: About
new NavigationStart(8, '/home', 'popstate', { navigationId: 6 }),
new NavigationEnd(8, '/home', '/home'),

// 9. Click the forward button
// Current: About
// Previous: Home
// Next: None
new NavigationStart(9, '/about', 'popstate', { navigationId: 7 }),
new NavigationEnd(9, '/about', '/about'),

// 10. Click the back button
// Current: Home
// Previous: Products
// Next: About
new NavigationStart(10, '/home', 'popstate', { navigationId: 8 }),
new NavigationEnd(10, '/home', '/home'),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, inject, NgZone } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { firstValueFrom } from 'rxjs';
import {
provideRouterHistoryStore,
RouterHistoryStore,
} from './router-history.store';

function createTestComponent(name: string, selector: string) {
@Component({ standalone: true, selector, template: name })
class TestComponent {}

return TestComponent;
}

@Component({
standalone: true,
selector: 'ngw-test-app',
imports: [AsyncPipe, NgIf, RouterLink, RouterOutlet],
template: `
<a
id="back-link"
*ngIf="routerHistory.previousUrl$ | async as previousUrl"
[href]="previousUrl"
(click)="onBack($event)"
>&lt; Back</a
>

<a
id="forward-link"
*ngIf="routerHistory.nextUrl$ | async as nextUrl"
[href]="nextUrl"
(click)="onNext($event)"
>&gt; Next</a
>

<a id="home-link" routerLink="/">Home</a>
<a id="about-link" routerLink="about">About</a>
<a id="company-link" routerLink="company">Company</a>
<a id="products-link" routerLink="products">Products</a>

<router-outlet></router-outlet>
`,
})
class TestAppComponent {
protected routerHistory = inject(RouterHistoryStore);

onBack(event: MouseEvent) {
event.preventDefault();
this.routerHistory.onNavigateBack();
}

onNext(event: MouseEvent) {
event.preventDefault();
this.routerHistory.onNavigateForward();
}
}

describe(RouterHistoryStore.name, () => {
async function setup() {
TestBed.configureTestingModule({
imports: [
TestAppComponent,
RouterTestingModule.withRoutes([
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
component: createTestComponent('HomeComponent', 'test-home'),
},
{
path: 'about',
component: createTestComponent('AboutComponent', 'test-about'),
},
{
path: 'company',
component: createTestComponent('CompanyComponent', 'test-company'),
},
{
path: 'products',
component: createTestComponent(
'ProductsComponent',
'test-products'
),
},
]),
],
providers: [provideRouterHistoryStore()],
});

const rootFixture = TestBed.createComponent(TestAppComponent);
const router = TestBed.inject(Router);
const ngZone = TestBed.inject(NgZone);
const routerHistory = TestBed.inject(RouterHistoryStore);

rootFixture.autoDetectChanges();
ngZone.run(() => router.initialNavigation());

return {
async click(selector: string) {
const link = rootFixture.debugElement.query(By.css(selector))
.nativeElement as HTMLElement;
ngZone.run(() => link.click());
await rootFixture.whenStable();
},
routerHistory,
};
}

it('the URLs behave like the History API when navigating using links', async () => {
expect.assertions(3);

const { click, routerHistory } = await setup();

// At Home
// Previous: None
// Next: None
await click('#about-link');
// At About
// Previous: Home
// Next: None
await click('#company-link');
// At Company
// Previous: About
// Next: None
await click('#products-link');
// At Products
// Previous: Company
// Next: None

expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products');
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/company');
expect(await firstValueFrom(routerHistory.nextUrl$)).toBe(undefined);
});

it('the URLs behave like the History API when navigating back', async () => {
expect.assertions(3);

const { click, routerHistory } = await setup();

// At Home
// Previous: None
// Next: None
await click('#about-link');
// At About
// Previous: Home
// Next: None
await click('#company-link');
// At Company
// Previous: About
// Next: None
await click('#back-link');
// At About
// Previous: Home
// Next: Company

expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/about');
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/home');
expect(await firstValueFrom(routerHistory.nextUrl$)).toBe('/company');
});

it('the URLs behave like the History API when navigating back twice', async () => {
expect.assertions(3);

const { click, routerHistory } = await setup();

// At Home
// Previous: None
// Next: None
await click('#about-link');
// At About
// Previous: Home
// Next: None
await click('#company-link');
// At Company
// Previous: About
// Next: None
await click('#back-link');
// At About
// Previous: Home
// Next: Company
await click('#back-link');
// At Home
// Previous: None
// Next: About

expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/home');
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe(undefined);
expect(await firstValueFrom(routerHistory.nextUrl$)).toBe('/about');
});

it('the URLs behave like the History API when navigating back twice then forward', async () => {
expect.assertions(3);

const { click, routerHistory } = await setup();

// At Home
// Previous: None
// Next: None
await click('#about-link');
// At About
// Previous: Home
// Next: None
await click('#company-link');
// At Company
// Previous: About
// Next: None
await click('#back-link');
// At About
// Previous: Home
// Next: Company
await click('#back-link');
// At Home
// Previous: None
// Next: About
await click('#forward-link');
// At About
// Previous: Home
// Next: Company

expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/about');
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/home');
expect(await firstValueFrom(routerHistory.nextUrl$)).toBe('/company');
});

it('the URLs behave like the History API when navigating back then using links', async () => {
expect.assertions(3);

const { click, routerHistory } = await setup();

// At Home
// Previous: None
// Next: None
await click('#about-link');
// At About
// Previous: Home
// Next: None
await click('#company-link');
// At Company
// Previous: About
// Next: None
await click('#back-link');
// At About
// Previous: Home
// Next: Company
await click('#products-link');
// At Products
// Previous: About
// Next: None

expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products');
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/about');
expect(await firstValueFrom(routerHistory.nextUrl$)).toBe(undefined);
});

it('the URLs behave like the History API when navigating back then forward', async () => {
expect.assertions(3);

const { click, routerHistory } = await setup();

// At Home
await click('#about-link');
// At About
// Previous: Home
// Next: None
await click('#company-link');
// At Company
// Previous: About
// Next: None
await click('#back-link');
// At About
// Previous: Home
// Next: Company
await click('#forward-link');
// At Company
// Previous: About
// Next: None

expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/company');
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/about');
expect(await firstValueFrom(routerHistory.nextUrl$)).toBe(undefined);
});
});
Loading