diff --git a/.gitignore b/.gitignore index e7c782ae..2dcf86ca 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ node_modules/ !.vscode/extensions.json # misc -.angular/ +.angular .nx/cache/ .nx/workspace-data/ /.sass-cache diff --git a/packages/router-store/.eslintrc.json b/packages/router-store/.eslintrc.json new file mode 100644 index 00000000..b8d18fc9 --- /dev/null +++ b/packages/router-store/.eslintrc.json @@ -0,0 +1,46 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "parserOptions": { + "project": ["packages/router-store/tsconfig.*?.json"] + }, + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "ngw", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "ngw", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/packages/router-store/README.md b/packages/router-store/README.md new file mode 100644 index 00000000..7b7f7a9d --- /dev/null +++ b/packages/router-store/README.md @@ -0,0 +1,7 @@ +# router-store + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test router-store` to execute the unit tests. diff --git a/packages/router-store/jest.config.ts b/packages/router-store/jest.config.ts new file mode 100644 index 00000000..7c6daf2a --- /dev/null +++ b/packages/router-store/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'router-store', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/packages/router-store', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/packages/router-store/ng-package.json b/packages/router-store/ng-package.json new file mode 100644 index 00000000..ec4042a0 --- /dev/null +++ b/packages/router-store/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/packages/router-store", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/packages/router-store/package.json b/packages/router-store/package.json new file mode 100644 index 00000000..a43003a1 --- /dev/null +++ b/packages/router-store/package.json @@ -0,0 +1,43 @@ +{ + "name": "@ngworker/router-store", + "version": "17.0.0", + "description": "A strictly typed lightweight alternative to NgRx Router Store and ActivatedRoute using Angular Signals", + "license": "MIT", + "sideEffects": false, + "peerDependencies": { + "@angular/core": "^17.0.0", + "@angular/router": "^17.0.0" + }, + "dependencies": {}, + "keywords": [ + "angular", + "router", + "signals", + "ngworker" + ], + "author": { + "name": "Lars Gyrup Brink Nielsen", + "email": "larsbrinknielsen@gmail.com", + "url": "https://github.com/LayZeeDK" + }, + "contributors": [], + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/LayZeeDK" + } + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "tag": "unknown" + }, + "bugs": { + "url": "https://github.com/ngworker/router-component-store/issue" + }, + "homepage": "https://github.com/ngworker/router-component-store#readme", + "repository": { + "type": "git", + "url": "https://github.com/ngworker/router-component-store" + } +} diff --git a/packages/router-store/project.json b/packages/router-store/project.json new file mode 100644 index 00000000..ae9ec3bf --- /dev/null +++ b/packages/router-store/project.json @@ -0,0 +1,37 @@ +{ + "name": "router-store", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/router-store/src", + "prefix": "ngworker", + "tags": [], + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "packages/router-store/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "packages/router-store/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "packages/router-store/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/router-store/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/packages/router-store/src/index.ts b/packages/router-store/src/index.ts new file mode 100644 index 00000000..f4f202a9 --- /dev/null +++ b/packages/router-store/src/index.ts @@ -0,0 +1,11 @@ +// GlobalRouterStore +export * from './lib/global-router-store/provide-global-router-store'; +// Serializable route state +export * from './lib/@ngrx/router-store/minimal-activated-route-state-snapshot'; +// LocalRouterStore +export * from './lib/local-router-store/provide-local-router-store'; +// RouterStore +export * from './lib/router-store'; +export * from './lib/strict-query-params'; +export * from './lib/strict-route-data'; +export * from './lib/strict-route-params'; diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts new file mode 100644 index 00000000..99727b97 --- /dev/null +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal-activated-route-state-snapshot.ts @@ -0,0 +1,89 @@ +/** + * @license + * The MIT License (MIT) + * + * Copyright (c) 2017 Brandon Roberts, Mike Ryan, Victor Savkin, Rob Wormald + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { ActivatedRouteSnapshot } from '@angular/router'; +import { StrictQueryParams } from '../../strict-query-params'; +import { StrictRouteData } from '../../strict-route-data'; +import { StrictRouteParams } from '../../strict-route-params'; + +/** + * Contains the information about a route associated with a component loaded in + * an outlet at a particular moment in time. MinimalActivatedRouteSnapshot can + * also be used to traverse the router state tree. + */ +export interface MinimalActivatedRouteSnapshot { + /** + * The configuration used to match this route. + */ + readonly routeConfig: ActivatedRouteSnapshot['routeConfig']; + /** + * The URL segments matched by this route. + */ + readonly url: ActivatedRouteSnapshot['url']; + /** + * The matrix parameters scoped to this route. + */ + readonly params: StrictRouteParams; + /** + * The query parameters shared by all the routes. + */ + readonly queryParams: StrictQueryParams; + /** + * The URL fragment shared by all the routes. + */ + readonly fragment: ActivatedRouteSnapshot['fragment']; + /** + * The static and resolved data of this route. + * + * @remarks + * Contains serializable route `Data` without its symbol index, in particular + * without the `Symbol.for(RouteTitle)` key as this is an internal value for + * the Angular `Router`. Instead, we access the resolved route title through + * `MinimalActivatedRouteSnapshot['title']`. + */ + readonly data: StrictRouteData; + /** + * The outlet name of the route. + */ + readonly outlet: ActivatedRouteSnapshot['outlet']; + /** + * The resolved route title. + */ + readonly title: ActivatedRouteSnapshot['title']; + /** + * The first child of this route in the router state tree + */ + readonly firstChild?: MinimalActivatedRouteSnapshot; + /** + * The children of this route in the router state tree. + */ + readonly children: MinimalActivatedRouteSnapshot[]; +} diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts new file mode 100644 index 00000000..5f9864f8 --- /dev/null +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal-router-state-snapshot.ts @@ -0,0 +1,30 @@ +/** + * @license + * The MIT License (MIT) + * + * Copyright (c) 2017 Brandon Roberts, Mike Ryan, Victor Savkin, Rob Wormald + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { MinimalActivatedRouteSnapshot } from './minimal-activated-route-state-snapshot'; + +export interface MinimalRouterStateSnapshot { + readonly root: MinimalActivatedRouteSnapshot; + readonly url: string; +} diff --git a/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts b/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts new file mode 100644 index 00000000..8f106466 --- /dev/null +++ b/packages/router-store/src/lib/@ngrx/router-store/minimal_serializer.ts @@ -0,0 +1,83 @@ +/** + * @license + * The MIT License (MIT) + * + * Copyright (c) 2017 Brandon Roberts, Mike Ryan, Victor Savkin, Rob Wormald + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Data, + RouterStateSnapshot, +} from '@angular/router'; +import { InternalStrictRouteData } from '../../internal-strict-route-data'; +import { InternalStrictRouteParams } from '../../internal-strict-route-params'; +import { MinimalActivatedRouteSnapshot } from './minimal-activated-route-state-snapshot'; +import { MinimalRouterStateSnapshot } from './minimal-router-state-snapshot'; + +@Injectable({ + providedIn: 'root', +}) +export class MinimalRouterStateSerializer { + serialize(routerState: RouterStateSnapshot): MinimalRouterStateSnapshot { + return { + root: this.#serializeRouteSnapshot(routerState.root), + url: routerState.url, + }; + } + + #serializeRouteData(routeData: Data): InternalStrictRouteData { + return Object.fromEntries(Object.entries(routeData)); + } + + #serializeRouteSnapshot( + routeSnapshot: ActivatedRouteSnapshot + ): MinimalActivatedRouteSnapshot { + const children = routeSnapshot.children.map((childRouteSnapshot) => + this.#serializeRouteSnapshot(childRouteSnapshot) + ); + return { + params: routeSnapshot.params as InternalStrictRouteParams, + data: this.#serializeRouteData( + routeSnapshot.data + ) as InternalStrictRouteData, + url: routeSnapshot.url, + outlet: routeSnapshot.outlet, + title: routeSnapshot.title, + routeConfig: routeSnapshot.routeConfig + ? { + path: routeSnapshot.routeConfig.path, + pathMatch: routeSnapshot.routeConfig.pathMatch, + redirectTo: routeSnapshot.routeConfig.redirectTo, + outlet: routeSnapshot.routeConfig.outlet, + title: + typeof routeSnapshot.routeConfig.title === 'string' + ? routeSnapshot.routeConfig.title + : undefined, + } + : null, + queryParams: routeSnapshot.queryParams as InternalStrictRouteParams, + fragment: routeSnapshot.fragment, + firstChild: children[0], + children, + }; + } +} diff --git a/packages/router-store/src/lib/global-router-store/global-router-store.ts b/packages/router-store/src/lib/global-router-store/global-router-store.ts new file mode 100644 index 00000000..e236f8c4 --- /dev/null +++ b/packages/router-store/src/lib/global-router-store/global-router-store.ts @@ -0,0 +1,102 @@ +import { + DestroyRef, + Injectable, + Signal, + WritableSignal, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { MinimalRouterStateSnapshot } from '../@ngrx/router-store/minimal-router-state-snapshot'; +import { MinimalRouterStateSerializer } from '../@ngrx/router-store/minimal_serializer'; +import { InternalStrictQueryParams } from '../internal-strict-query-params'; +import { InternalStrictRouteData } from '../internal-strict-route-data'; +import { InternalStrictRouteParams } from '../internal-strict-route-params'; +import { isNavigationEvent } from '../is-navigation-event'; +import { RouterStore } from '../router-store'; +import { StrictQueryParams } from '../strict-query-params'; +import { StrictRouteData } from '../strict-route-data'; +import { StrictRouteParams } from '../strict-route-params'; + +@Injectable({ + providedIn: 'root', +}) +export class GlobalRouterStore extends RouterStore { + readonly #router = inject(Router); + readonly #serializer = inject(MinimalRouterStateSerializer); + readonly #destroyRef = inject(DestroyRef); + + readonly #routerStateSignal: WritableSignal = + signal(this.#serializer.serialize(this.#router.routerState.snapshot)); + + readonly #rootRoute = computed( + (): MinimalActivatedRouteSnapshot => this.#routerStateSignal().root + ); + + readonly currentRoute = computed((): MinimalActivatedRouteSnapshot => { + let route = this.#rootRoute(); + + while (route.firstChild) { + route = route.firstChild; + } + + return route; + }); + + readonly fragment = computed((): string | null => this.#rootRoute().fragment); + + readonly queryParams = computed( + (): StrictQueryParams => this.#rootRoute().queryParams + ); + + readonly routeData = computed( + (): StrictRouteData => this.currentRoute().data + ); + + readonly routeParams = computed( + (): StrictRouteParams => this.currentRoute().params + ); + + readonly title = computed( + (): string | undefined => this.currentRoute().title + ); + + readonly url = computed((): string => this.#routerStateSignal().url); + + constructor() { + super(); + + // Listen to router events to update state + this.#router.events + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((routerEvent) => { + if (isNavigationEvent(routerEvent)) { + const routerState = this.#serializer.serialize( + this.#router.routerState.snapshot + ); + this.#routerStateSignal.set(routerState); + } + }); + } + + selectQueryParam( + param: string + ): Signal { + return computed( + () => (this.queryParams() as InternalStrictQueryParams)[param] + ); + } + + selectRouteDataParam(key: string): Signal { + return computed(() => (this.routeData() as InternalStrictRouteData)[key]); + } + + selectRouteParam(param: string): Signal { + return computed( + () => (this.routeParams() as InternalStrictRouteParams)[param] + ); + } +} diff --git a/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts b/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts new file mode 100644 index 00000000..67989446 --- /dev/null +++ b/packages/router-store/src/lib/global-router-store/provide-global-router-store.ts @@ -0,0 +1,43 @@ +import { ClassProvider, Provider } from '@angular/core'; +import { MinimalRouterStateSerializer } from '../@ngrx/router-store/minimal_serializer'; +import { RouterStore } from '../router-store'; +import { GlobalRouterStore } from './global-router-store'; + +/** + * Provide an application-wide router store that can be injected in any class. + * + * Use this provider factory in a root environment injector. + * + * @returns The providers required for a global router store. + * + * @example + * // Providing in a standalone Angular application + * // main.ts + * // (...) + * import { provideGlobalRouterStore } from '@ngworker/router-store'; + * + * bootstrapApplication(AppComponent, { + * providers: [provideGlobalRouterStore()], + * }).catch((error) => console.error(error)); + * + * + * @example + * // Providing in a classic Angular application + * // app.module.ts + * // (...) + * import { provideGlobalRouterStore } from '@ngworker/router-store'; + * + * (@)NgModule({ + * // (...) + * providers: [provideGlobalRouterStore()], + * }) + * export class AppModule {} + */ +export function provideGlobalRouterStore(): Provider[] { + const globalRouterStoreProvider: ClassProvider = { + provide: RouterStore, + useClass: GlobalRouterStore, + }; + + return [MinimalRouterStateSerializer, globalRouterStoreProvider]; +} diff --git a/packages/router-store/src/lib/global-router-store/selectors.spec.ts b/packages/router-store/src/lib/global-router-store/selectors.spec.ts new file mode 100644 index 00000000..96c8fbaf --- /dev/null +++ b/packages/router-store/src/lib/global-router-store/selectors.spec.ts @@ -0,0 +1,129 @@ +import { Component } from '@angular/core'; +import { Route } from '@angular/router'; +import { createFeatureHarness } from '@ngworker/spectacular'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { RouterStore } from '../router-store'; +import { StrictRouteData } from '../strict-route-data'; +import { GlobalRouterStore } from './global-router-store'; +import { provideGlobalRouterStore } from './provide-global-router-store'; + +@Component({ + standalone: true, + template: '', +}) +class DummyAuthComponent {} + +describe(`${GlobalRouterStore.name} selectors`, () => { + function setup({ + data = {}, + title, + }: { + readonly data?: Route['data']; + readonly title?: Route['title']; + } = {}) { + const harness = createFeatureHarness({ + featurePath: '', + providers: [provideGlobalRouterStore()], + routes: [ + { + path: ':token', + component: DummyAuthComponent, + data, + title, + }, + ], + }); + + return { harness }; + } + + it('exposes a selector for the current route', async () => { + const { harness } = setup({ + data: { + testData: 'test-data', + }, + title: 'Static title', + }); + + await harness.router.navigateByUrl( + '/bqbNGrezShfz?ref=ngworker.github.io#test-fragment' + ); + + const expectedRouteSnapshot: Partial = { + children: [], + data: { + testData: 'test-data', + }, + fragment: 'test-fragment', + outlet: 'primary', + params: { + token: 'bqbNGrezShfz', + }, + queryParams: { + ref: 'ngworker.github.io', + }, + title: 'Static title', + }; + + const currentRoute = harness.inject(RouterStore).currentRoute(); + expect(currentRoute).toEqual( + expect.objectContaining(expectedRouteSnapshot) + ); + }); + + it('exposes a selector for route data', async () => { + const expectedRouteData: StrictRouteData = { + testData: 'test-data', + }; + const { harness } = setup({ + data: expectedRouteData, + }); + + await harness.router.navigateByUrl( + '/VDhyGSDTYfvz?ref=ngworker.github.io#test-fragment' + ); + + const routeData = harness.inject(RouterStore).routeData(); + expect(routeData).toEqual(expectedRouteData); + }); + + it('creates a selector for specific route data', async () => { + const expectedTestData = 'test-data'; + const { harness } = setup({ + data: { + testData: expectedTestData, + }, + }); + + await harness.router.navigateByUrl( + '/SFUXQFSDgMyw?ref=ngworker.github.io#test-fragment' + ); + + const testData = harness + .inject(RouterStore) + .selectRouteDataParam('testData')(); + expect(testData).toBe(expectedTestData); + }); + + it('creates a selector for a specific query parameter', async () => { + const { harness } = setup(); + + await harness.router.navigateByUrl( + '/vOaURFhUDkYN?ref=ngworker.github.io#test-fragment' + ); + + const ref = harness.inject(RouterStore).selectQueryParam('ref')(); + expect(ref).toBe('ngworker.github.io'); + }); + + it('creates a selector for a specific route parameter', async () => { + const { harness } = setup(); + + await harness.router.navigateByUrl( + '/token?ref=ngworker.github.io#test-fragment' + ); + + const token = harness.inject(RouterStore).selectRouteParam('token')(); + expect(token).toBe('token'); + }); +}); diff --git a/packages/router-store/src/lib/internal-strict-query-params.ts b/packages/router-store/src/lib/internal-strict-query-params.ts new file mode 100644 index 00000000..97ad537f --- /dev/null +++ b/packages/router-store/src/lib/internal-strict-query-params.ts @@ -0,0 +1,10 @@ +import { Params } from '@angular/router'; +import { StrictNoAny } from './util-types/strict-no-any'; + +/** + * @remarks We use this type to ensure compatibility with {@link Params}. + * @internal + */ +export type InternalStrictQueryParams = Readonly< + StrictNoAny +>; diff --git a/packages/router-store/src/lib/internal-strict-route-data.ts b/packages/router-store/src/lib/internal-strict-route-data.ts new file mode 100644 index 00000000..5f7c0986 --- /dev/null +++ b/packages/router-store/src/lib/internal-strict-route-data.ts @@ -0,0 +1,11 @@ +import { Data } from '@angular/router'; +import { OmitSymbolIndex } from './util-types/omit-symbol-index'; +import { StrictNoAny } from './util-types/strict-no-any'; + +/** + * @remarks We use this type to ensure compatibility with {@link Data}. + * @internal + */ +export type InternalStrictRouteData = Readonly< + StrictNoAny> +>; diff --git a/packages/router-store/src/lib/internal-strict-route-params.ts b/packages/router-store/src/lib/internal-strict-route-params.ts new file mode 100644 index 00000000..5210fc65 --- /dev/null +++ b/packages/router-store/src/lib/internal-strict-route-params.ts @@ -0,0 +1,10 @@ +import { Params } from '@angular/router'; +import { StrictNoAny } from './util-types/strict-no-any'; + +/** + * @remarks We use this type to ensure compatibility with {@link Params}. + * @internal + */ +export type InternalStrictRouteParams = Readonly< + StrictNoAny +>; diff --git a/packages/router-store/src/lib/is-navigation-event.ts b/packages/router-store/src/lib/is-navigation-event.ts new file mode 100644 index 00000000..3e3a86e6 --- /dev/null +++ b/packages/router-store/src/lib/is-navigation-event.ts @@ -0,0 +1,23 @@ +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Event as RouterEvent, + RoutesRecognized, +} from '@angular/router'; + +/** + * @returns `true` when the specified Angular Router event is navigation event + * that causes `RouterStore` to synchronize its internal state. + * @returns `false` otherwise + */ +export function isNavigationEvent(routerEvent: RouterEvent): boolean { + return [ + NavigationStart, + RoutesRecognized, + NavigationEnd, + NavigationCancel, + NavigationError, + ].some((navigationEventType) => routerEvent instanceof navigationEventType); +} diff --git a/packages/router-store/src/lib/local-router-store/local-router-store.ts b/packages/router-store/src/lib/local-router-store/local-router-store.ts new file mode 100644 index 00000000..1bade8a5 --- /dev/null +++ b/packages/router-store/src/lib/local-router-store/local-router-store.ts @@ -0,0 +1,131 @@ +import { + DestroyRef, + Injectable, + Signal, + WritableSignal, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + Router, + RouterStateSnapshot, + createUrlTreeFromSnapshot, +} from '@angular/router'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { MinimalRouterStateSnapshot } from '../@ngrx/router-store/minimal-router-state-snapshot'; +import { MinimalRouterStateSerializer } from '../@ngrx/router-store/minimal_serializer'; +import { InternalStrictQueryParams } from '../internal-strict-query-params'; +import { InternalStrictRouteData } from '../internal-strict-route-data'; +import { InternalStrictRouteParams } from '../internal-strict-route-params'; +import { isNavigationEvent } from '../is-navigation-event'; +import { RouterStore } from '../router-store'; +import { StrictQueryParams } from '../strict-query-params'; +import { StrictRouteData } from '../strict-route-data'; +import { StrictRouteParams } from '../strict-route-params'; + +function createRouterStateSnapshot( + route: ActivatedRoute, + router: Router +): RouterStateSnapshot { + return { + root: route.snapshot, + url: router.serializeUrl( + createUrlTreeFromSnapshot( + route.snapshot, + [], + route.snapshot.queryParams, + route.snapshot.fragment + ) + ), + }; +} + +function serializeRouterState( + route: ActivatedRoute, + router: Router, + serializer: MinimalRouterStateSerializer +): MinimalRouterStateSnapshot { + return serializer.serialize(createRouterStateSnapshot(route, router)); +} + +@Injectable() +export class LocalRouterStore extends RouterStore { + readonly #route = inject(ActivatedRoute); + readonly #router = inject(Router); + readonly #serializer = inject(MinimalRouterStateSerializer); + readonly #destroyRef = inject(DestroyRef); + + readonly #routerStateSignal: WritableSignal = + signal(serializeRouterState(this.#route, this.#router, this.#serializer)); + + readonly currentRoute = computed( + (): MinimalActivatedRouteSnapshot => this.#routerStateSignal().root + ); + + readonly fragment: Signal = toSignal(this.#route.fragment, { + requireSync: true, + }); + + readonly queryParams: Signal = toSignal( + this.#route.queryParams, + { + requireSync: true, + } + ); + + readonly routeData: Signal = toSignal(this.#route.data, { + requireSync: true, + }); + + readonly routeParams: Signal = toSignal( + this.#route.params, + { + requireSync: true, + } + ); + + readonly title: Signal = toSignal(this.#route.title, { + requireSync: true, + }); + + readonly url = computed((): string => this.#routerStateSignal().url); + + constructor() { + super(); + + // Listen to router events to update state + this.#router.events + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((routerEvent) => { + if (isNavigationEvent(routerEvent)) { + const routerState = serializeRouterState( + this.#route, + this.#router, + this.#serializer + ); + this.#routerStateSignal.set(routerState); + } + }); + } + + selectQueryParam( + param: string + ): Signal { + return computed( + () => (this.queryParams() as InternalStrictQueryParams)[param] + ); + } + + selectRouteDataParam(key: string): Signal { + return computed(() => (this.routeData() as InternalStrictRouteData)[key]); + } + + selectRouteParam(param: string): Signal { + return computed( + () => (this.routeParams() as InternalStrictRouteParams)[param] + ); + } +} diff --git a/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts b/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts new file mode 100644 index 00000000..7e9cb8cb --- /dev/null +++ b/packages/router-store/src/lib/local-router-store/provide-local-router-store.ts @@ -0,0 +1,41 @@ +import { ClassProvider, Provider } from '@angular/core'; +import { RouterStore } from '../router-store'; +import { LocalRouterStore } from './local-router-store'; + +/** + * Provide a component-level router store that can be injected in any directive, + * component, pipe, or component-level service. + * + * Use this provider factory in `Component.providers` or + * `Component.viewProviders` to make a local router store available to a + * component sub-tree. + * + * @returns The providers required for a local router store. + * + * @example + * // Providing and injecting in a component + * // hero-detail.component.ts + * // (...) + * import { + * provideLocalRouterStore, + * RouterStore, + * } from '@ngworker/router-store'; + * + * (@)Component({ + * // (...) + * providers: [provideLocalRouterStore()], + * }) + * export class HeroDetailComponent { + * #routerStore = inject(RouterStore); + * + * heroId = this.#routerStore.selectRouteParam('id'); + * } + */ +export function provideLocalRouterStore(): Provider[] { + const localRouterStoreProvider: ClassProvider = { + provide: RouterStore, + useClass: LocalRouterStore, + }; + + return [localRouterStoreProvider]; +} diff --git a/packages/router-store/src/lib/router-store.ts b/packages/router-store/src/lib/router-store.ts new file mode 100644 index 00000000..1a7b1089 --- /dev/null +++ b/packages/router-store/src/lib/router-store.ts @@ -0,0 +1,93 @@ +import { Signal } from '@angular/core'; +import { MinimalActivatedRouteSnapshot } from './@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { StrictQueryParams } from './strict-query-params'; +import { StrictRouteData } from './strict-route-data'; +import { StrictRouteParams } from './strict-route-params'; + +/** + * An Angular Router-connecting store using Angular Signals. + * + * A `RouterStore` service is provided by using either + * `provideGlobalRouterStore` or `provideLocalRouterStore`. + * + * The _global_ `RouterStore` service is provided in a root environment injector + * and is never destroyed but can be injected in any class. + * + * A _local_ `RouterStore` requires a component-level provider, follows the + * lifecycle of that component, and can be injected in declarables as well as + * other component-level services. + * + * @example + * // Usage in a component + * // hero-detail.component.ts + * // (...) + * import { RouterStore } from '@ngworker/router-store'; + * + * (@)Component({ + * // (...) + * }) + * export class HeroDetailComponent { + * #routerStore = inject(RouterStore); + * + * heroId = this.#routerStore.selectRouteParam('id'); + * } + */ +export abstract class RouterStore { + /** + * Select the current route. + */ + abstract readonly currentRoute: Signal; + /** + * Select the current route fragment. + */ + abstract readonly fragment: Signal; + /** + * Select the current route query parameters. + */ + abstract readonly queryParams: Signal; + /** + * Select the current route data. + */ + abstract readonly routeData: Signal; + /** + * Select the current route parameters. + */ + abstract readonly routeParams: Signal; + /** + * Select the resolved route title. + */ + abstract readonly title: Signal; + /** + * Select the current URL. + */ + abstract readonly url: Signal; + /** + * Select the specified route data. + * + * @param key The route data key. + * + * @example Usage + * const limit = computed(() => Number(routerStore.selectRouteDataParam('limit')())); + */ + abstract selectRouteDataParam(key: string): Signal; + /** + * Select the specified query parameter. + * + * @param param The name of the query parameter. + * + * @example Usage + * const order = routerStore.selectQueryParam('order'); + */ + abstract selectQueryParam( + param: string + ): Signal; + /** + * Select the specified route parameter. + * + * @param param The name of the route parameter. + * + * @example Usage + * const id = routerStore.selectRouteParam('id'); + */ + abstract selectRouteParam(param: string): Signal; +} diff --git a/packages/router-store/src/lib/strict-query-params.ts b/packages/router-store/src/lib/strict-query-params.ts new file mode 100644 index 00000000..c4797027 --- /dev/null +++ b/packages/router-store/src/lib/strict-query-params.ts @@ -0,0 +1,11 @@ +// [@typescript-eslint/no-unused-vars] Used in TSDoc. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Params } from '@angular/router'; + +/** + * Strict route query {@link Params} with read-only members where the `any` member + * type is converted to `string | readonly string[] | undefined`. + */ +export interface StrictQueryParams { + readonly [param: string]: string | readonly string[] | undefined; +} diff --git a/packages/router-store/src/lib/strict-route-data.ts b/packages/router-store/src/lib/strict-route-data.ts new file mode 100644 index 00000000..0a1866fc --- /dev/null +++ b/packages/router-store/src/lib/strict-route-data.ts @@ -0,0 +1,10 @@ +/** + * Serializable route `Data` without its symbol index, in particular without the + * `Symbol(RouteTitle)` key as this is an internal value for the Angular + * `Router`. + * + * Additionally, the `any` member type is converted to `unknown`. + */ +export interface StrictRouteData { + readonly [key: string]: unknown; +} diff --git a/packages/router-store/src/lib/strict-route-params.ts b/packages/router-store/src/lib/strict-route-params.ts new file mode 100644 index 00000000..8a037c5c --- /dev/null +++ b/packages/router-store/src/lib/strict-route-params.ts @@ -0,0 +1,7 @@ +/** + * Strict route {@link Params} with read-only members where the `any` member + * type is converted to `string | undefined`. + */ +export interface StrictRouteParams { + readonly [param: string]: string | undefined; +} diff --git a/packages/router-store/src/lib/util-types/omit-symbol-index.ts b/packages/router-store/src/lib/util-types/omit-symbol-index.ts new file mode 100644 index 00000000..c1f9d70d --- /dev/null +++ b/packages/router-store/src/lib/util-types/omit-symbol-index.ts @@ -0,0 +1,16 @@ +/** + * Remove `symbol` index signature from the specified type. + * + * @example Usage + * ``` + * type RouteData = { [key: string | symbol]: any; }; + * type SerializableRouteData = OmitSymbolIndex; + * ``` + * + * `SerializableRouteData` is `{ [key: string]: any }`. + */ +export type OmitSymbolIndex = { + [TShapeKey in keyof TShape as symbol extends TShapeKey + ? never + : TShapeKey]: TShape[TShapeKey]; +}; diff --git a/packages/router-store/src/lib/util-types/strict-no-any.ts b/packages/router-store/src/lib/util-types/strict-no-any.ts new file mode 100644 index 00000000..6eb074f2 --- /dev/null +++ b/packages/router-store/src/lib/util-types/strict-no-any.ts @@ -0,0 +1,19 @@ +/** + * Convert `any` member types to `unknown` or a specified type in the specified + * type. + * + * @example Usage + * ``` + * type RouteData = { [key: string | symbol]: any; }; + * type StrictRouteData = Strict; + * ``` + * + * `StrictRouteData` is `{ [key: string | symbol]: unknown }`. + */ +export type StrictNoAny = { + // [@typescript-eslint/no-explicit-any] We detect `any` to convert it to the specified type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [TShapeKey in keyof TShape]: TShape[TShapeKey] extends any + ? TStrictType + : TShape[TShapeKey]; +}; diff --git a/packages/router-store/src/test-setup.ts b/packages/router-store/src/test-setup.ts new file mode 100644 index 00000000..ab1eeeb3 --- /dev/null +++ b/packages/router-store/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; +import 'jest-preset-angular/setup-jest'; diff --git a/packages/router-store/tsconfig.json b/packages/router-store/tsconfig.json new file mode 100644 index 00000000..92049739 --- /dev/null +++ b/packages/router-store/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/packages/router-store/tsconfig.lib.json b/packages/router-store/tsconfig.lib.json new file mode 100644 index 00000000..063e5257 --- /dev/null +++ b/packages/router-store/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/router-store/tsconfig.lib.prod.json b/packages/router-store/tsconfig.lib.prod.json new file mode 100644 index 00000000..2a2faa88 --- /dev/null +++ b/packages/router-store/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/packages/router-store/tsconfig.spec.json b/packages/router-store/tsconfig.spec.json new file mode 100644 index 00000000..53fbfcdc --- /dev/null +++ b/packages/router-store/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2b56a80d..9289577f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,7 +20,8 @@ ], "@ngworker/router-signal-store": [ "packages/router-signal-store/src/index.ts" - ] + ], + "@ngworker/router-store": ["packages/router-store/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]