From 097819ad720379eb1da2abe2e937441d788956bf Mon Sep 17 00:00:00 2001 From: Enea Jahollari Date: Fri, 15 Nov 2024 20:52:48 +0100 Subject: [PATCH] feat: add linkedQueryParam implementation, tests and docs The `linkedQueryParam` utility function creates a signal that is linked to a query parameter in the URL. This allows you to easily keep your Angular application's state in sync with the URL, making it easier to share and bookmark specific views. --- apps/test-app/src/app/app.component.ts | 24 + apps/test-app/src/app/app.config.ts | 5 + .../linked-query-param-array.component.ts | 53 ++ .../linked-query-param-basic.component.ts | 71 +++ .../linked-query-param-booleans.component.ts | 58 ++ ...nked-query-param-insideObject.component.ts | 143 +++++ .../linked-query-param-number.component.ts | 70 +++ .../linked-query-param-object.component.ts | 134 +++++ .../linked-query-param.component.ts | 80 +++ .../src/app/linked-query-param/routes.ts | 46 ++ .../utilities/Injectors/linked-query-param.md | 289 ++++++++++ .../create-notifier/src/create-notifier.ts | 5 + libs/ngxtension/linked-query-param/README.md | 3 + .../linked-query-param/ng-package.json | 5 + .../linked-query-param/project.json | 27 + .../linked-query-param/src/index.ts | 1 + .../src/linked-query-param.spec.ts | 508 ++++++++++++++++++ .../src/linked-query-param.ts | 414 ++++++++++++++ tsconfig.base.json | 3 + 19 files changed, 1939 insertions(+) create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param-array.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param-basic.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param-booleans.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param-insideObject.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param-number.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param-object.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/linked-query-param.component.ts create mode 100644 apps/test-app/src/app/linked-query-param/routes.ts create mode 100644 docs/src/content/docs/utilities/Injectors/linked-query-param.md create mode 100644 libs/ngxtension/linked-query-param/README.md create mode 100644 libs/ngxtension/linked-query-param/ng-package.json create mode 100644 libs/ngxtension/linked-query-param/project.json create mode 100644 libs/ngxtension/linked-query-param/src/index.ts create mode 100644 libs/ngxtension/linked-query-param/src/linked-query-param.spec.ts create mode 100644 libs/ngxtension/linked-query-param/src/linked-query-param.ts diff --git a/apps/test-app/src/app/app.component.ts b/apps/test-app/src/app/app.component.ts index f476b3dc..09e0379f 100644 --- a/apps/test-app/src/app/app.component.ts +++ b/apps/test-app/src/app/app.component.ts @@ -52,10 +52,34 @@ import { RouterLink, RouterOutlet } from '@angular/router';
  • Form Events
  • + +
  • + Linked Query Param +

  • `, + + styles: ` + ul { + display: flex; + justify-content: flex-start; + gap: 5px; + list-style: none; + } + + a { + text-decoration: none; + border: 1px solid black; + padding: 5px; + color: black; + } + a:hover { + background: black; + color: white; + } + `, }) export class AppComponent {} diff --git a/apps/test-app/src/app/app.config.ts b/apps/test-app/src/app/app.config.ts index b84dc84e..71a77058 100644 --- a/apps/test-app/src/app/app.config.ts +++ b/apps/test-app/src/app/app.config.ts @@ -74,6 +74,11 @@ export const appConfig: ApplicationConfig = { loadComponent: () => import('./control-value-accessor/control-value-accessor'), }, + { + path: 'linked-query-param', + loadChildren: () => + import('./linked-query-param/routes').then((x) => x.routes), + }, ]), ], }; diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param-array.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param-array.component.ts new file mode 100644 index 00000000..4f11883a --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param-array.component.ts @@ -0,0 +1,53 @@ +import { JsonPipe } from '@angular/common'; +import { Component, effect } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +@Component({ + standalone: true, + template: ` +
    + @for (id of IDs; track $index) { + + } +
    + `, + imports: [FormsModule, JsonPipe], + styles: ` + div { + padding: 20px; + } + `, +}) +export default class LinkedQueryParamArrayCmp { + IDs = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; + + selectedCategoriesIds = linkedQueryParam('selectedCategoriesIds', { + parse: (x) => (x ? x.split(',').map((x) => x.trim()) : []), + serialize: (x) => x.join(','), + }); + + constructor() { + effect(() => { + console.log( + 'selectedCategoriesIds Value: ', + this.selectedCategoriesIds(), + ); + }); + } + + selectId(id: string) { + this.selectedCategoriesIds.update((x) => [...x, id]); + } + + deselectId(id: string) { + this.selectedCategoriesIds.update((x) => x.filter((x) => x !== id)); + } +} diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param-basic.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param-basic.component.ts new file mode 100644 index 00000000..beea15e7 --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param-basic.component.ts @@ -0,0 +1,71 @@ +import { JsonPipe } from '@angular/common'; +import { Component, effect, inject, untracked } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Title } from '@angular/platform-browser'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +@Component({ + standalone: true, + template: ` +
    +
    SearchQuery: {{ searchQuery() | json }}
    + +
    + +
    + + + + + + + + + + + + + + + +
    + `, + imports: [FormsModule, JsonPipe], + styles: ` + div { + padding: 20px; + } + `, +}) +export default class LinkedQueryParamStringCmp { + private titleService = inject(Title); + + searchQuery = linkedQueryParam('searchQuery', { + // NOTE in docs that serialize should come after parse in order for types to work correctly + // parse: (x) => x ? parseInt(x, 10) : null, + // serialize: (x) => x, + }); + + // differentSignalWithSearchQuery = linkedQueryParam('searchQuery', { + // defaultValue: 'default', + // }); + + constructor() { + effect(() => { + console.log('searchQuery Type: ', typeof this.searchQuery()); + console.log('searchQuery Value: ', this.searchQuery()); + + untracked(() => { + if (this.searchQuery()) { + this.titleService.setTitle(this.searchQuery()!); + } else { + this.titleService.setTitle('No search query!'); + } + }); + }); + } +} diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param-booleans.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param-booleans.component.ts new file mode 100644 index 00000000..882bb9f2 --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param-booleans.component.ts @@ -0,0 +1,58 @@ +import { JsonPipe } from '@angular/common'; +import { Component, effect } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +@Component({ + standalone: true, + template: ` +
    +
    Show Deleted: {{ showDeleted() | json }}
    + +
    + +
    +
    + +
    + + + + +
    + `, + imports: [FormsModule, JsonPipe], + styles: ` + div { + padding: 20px; + } + `, +}) +export default class LinkedQueryParamBooleansCmp { + showDeleted = linkedQueryParam('showDeleted', { + parse: (x) => x === 'true', + serialize: (x) => (x ? 'true' : 'false'), + }); + + showDeletedOtherSignal = linkedQueryParam('showDeleted', { + parse: (x) => x === 'true', + serialize: (x) => (x ? 'true' : 'false'), + }); + + constructor() { + effect(() => { + console.log('showDeleted Type: ', typeof this.showDeleted()); + console.log('showDeleted Value: ', this.showDeleted()); + }); + } +} diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param-insideObject.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param-insideObject.component.ts new file mode 100644 index 00000000..6dabb1be --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param-insideObject.component.ts @@ -0,0 +1,143 @@ +import { JsonPipe } from '@angular/common'; +import { Component, effect, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +interface MyFilters { + a: string; + b: string; + c: { + d: string; + e: string; + }; +} + +@Component({ + standalone: true, + template: ` +
    + +
    + + +
    + +
    + + +
    + + +
    + + + +
    + + + + + +
    +
    +
    +
    +
    +
    + `, + imports: [FormsModule, JsonPipe], + styles: ` + div { + padding: 20px; + } + `, +}) +export default class LinkedQueryParamInsideObjectCmp { + private router = inject(Router); + + filterState = { + searchQuery: linkedQueryParam('searchQuery'), + showDeleted: linkedQueryParam('showDeleted', { + parse: (x) => x === 'true', + }), + pageNumber: linkedQueryParam('page', { parse: (x) => (x ? +x : null) }), + pageSize: linkedQueryParam('pageSize', { parse: (x) => (x ? +x : null) }), + sortBy: linkedQueryParam('sortBy', { defaultValue: 'name' }), + direction: linkedQueryParam<'asc' | 'desc'>('direction', { + defaultValue: 'asc', + }), + }; + + constructor() { + Object.keys(this.filterState).forEach((key) => { + effect(() => { + // @ts-ignore + console.log(key, this.filterState[key]()); + }); + }); + } + + resetAll() { + this.router.navigate([], { + queryParams: { + searchQuery: null, + showDeleted: null, + page: null, + pageSize: null, + sortBy: null, + direction: null, + }, + }); + } +} diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param-number.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param-number.component.ts new file mode 100644 index 00000000..6eb1ce5a --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param-number.component.ts @@ -0,0 +1,70 @@ +import { JsonPipe } from '@angular/common'; +import { Component, effect } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +@Component({ + standalone: true, + template: ` +
    +
    Page Number: {{ pageNumber() | json }}
    +
    Page Size: {{ pageSize() | json }}
    + +
    + +
    + +
    + +
    + + + + + +
    + `, + imports: [FormsModule, JsonPipe], + styles: ` + div { + padding: 20px; + } + `, +}) +export default class LinkedQueryParamNumberCmp { + pageNumber = linkedQueryParam('page', { parse: (x) => (x ? +x : null) }); + pageSize = linkedQueryParam('pageSize', { parse: (x) => (x ? +x : null) }); + + constructor() { + effect(() => { + console.log('pageNumber Type: ', typeof this.pageNumber()); + console.log('pageNumber Value: ', this.pageNumber()); + }); + } + + changeBoth() { + this.pageNumber.set(20); + this.pageSize.set(30); + } +} diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param-object.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param-object.component.ts new file mode 100644 index 00000000..263a3b73 --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param-object.component.ts @@ -0,0 +1,134 @@ +import { JsonPipe } from '@angular/common'; +import { Component, effect, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +interface MyFilters { + a: string; + b: string; + c: { + d: string; + e: string; + }; +} + +@Component({ + standalone: true, + template: ` +
    + MyFilters: +
    +
    +        {{ myFilters() | json }}
    +      
    +
    + A: + +
    + B: + +
    + D: + +
    + E: + +
    +
    + + + + + + +
    +
    +
    +
    + `, + imports: [FormsModule, JsonPipe], + styles: ` + div { + padding: 20px; + } + `, +}) +export default class LinkedQueryParamObjectCmp { + private router = inject(Router); + + myFilters = linkedQueryParam('filters', { + parse: (x) => + x + ? JSON.parse(x) + : { + a: '', + b: '', + c: { + d: '', + e: '', + }, + }, + serialize: (x) => JSON.stringify(x), + }); + + constructor() { + effect(() => { + console.log('myFilters Value: ', this.myFilters()); + }); + } + + updateAFilter(a: string) { + this.myFilters.update((x) => ({ ...x, a })); + } + + updateBFilter(b: string) { + this.myFilters.update((x) => ({ ...x, b })); + } + + updateDFilter(d: string) { + this.myFilters.update((x) => ({ ...x, c: { ...x.c, d } })); + } + + updateEFilter(e: string) { + this.myFilters.update((x) => ({ ...x, c: { ...x.c, e } })); + } + + updateAll() { + this.myFilters.update((x) => { + return { + a: '1aa', + b: '2bb', + c: { + d: '3dd', + e: '4ee', + }, + }; + }); + } + + resetAll() { + this.router.navigate([], { + queryParams: { + filters: null, + }, + }); + } +} diff --git a/apps/test-app/src/app/linked-query-param/linked-query-param.component.ts b/apps/test-app/src/app/linked-query-param/linked-query-param.component.ts new file mode 100644 index 00000000..75667daa --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/linked-query-param.component.ts @@ -0,0 +1,80 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +interface MyFilters { + a: string; + b: string; + c: { + d: string; + e: string; + }; +} + +@Component({ + standalone: true, + template: ` +
    + + String based + + + + Boolean based + + + + Number based + + + + Object based + + + + Inside object + + + + Array based + +
    + +
    + +
    + `, + imports: [RouterLink, RouterLinkActive, RouterOutlet], + styles: ` + :host { + display: grid; + grid-template-columns: 300px 1fr; + } + + div { + padding: 20px; + display: flex; + flex-direction: column; + gap: 5px; + } + + a { + color: black; + text-decoration: none; + padding: 10px; + border: 1px solid black; + width: 200px; + } + a:hover { + background-color: lightgray; + } + + a.active { + background-color: darkblue; + color: white; + } + `, +}) +export default class LinkedQueryParamTypeCmp {} diff --git a/apps/test-app/src/app/linked-query-param/routes.ts b/apps/test-app/src/app/linked-query-param/routes.ts new file mode 100644 index 00000000..0f02264f --- /dev/null +++ b/apps/test-app/src/app/linked-query-param/routes.ts @@ -0,0 +1,46 @@ +import { Routes } from '@angular/router'; +import LinkedQueryParamArrayCmp from './linked-query-param-array.component'; +import LinkedQueryParamStringCmp from './linked-query-param-basic.component'; +import LinkedQueryParamBooleansCmp from './linked-query-param-booleans.component'; +import LinkedQueryParamInsideObjectCmp from './linked-query-param-insideObject.component'; +import LinkedQueryParamNumberCmp from './linked-query-param-number.component'; +import LinkedQueryParamObjectCmp from './linked-query-param-object.component'; +import LinkedQueryParamCmp from './linked-query-param.component'; + +export const routes: Routes = [ + { + path: '', + component: LinkedQueryParamCmp, + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'string', + }, + { + path: 'string', + component: LinkedQueryParamStringCmp, + }, + { + path: 'booleans', + component: LinkedQueryParamBooleansCmp, + }, + { + path: 'number', + component: LinkedQueryParamNumberCmp, + }, + { + path: 'object', + component: LinkedQueryParamObjectCmp, + }, + { + path: 'inside-object', + component: LinkedQueryParamInsideObjectCmp, + }, + { + path: 'array', + component: LinkedQueryParamArrayCmp, + }, + ], + }, +]; diff --git a/docs/src/content/docs/utilities/Injectors/linked-query-param.md b/docs/src/content/docs/utilities/Injectors/linked-query-param.md new file mode 100644 index 00000000..c9b63422 --- /dev/null +++ b/docs/src/content/docs/utilities/Injectors/linked-query-param.md @@ -0,0 +1,289 @@ +--- +title: linkedQueryParam +description: ngxtension/linked-query-param +entryPoint: ngxtension/linked-query-param +badge: experimental +contributors: ['enea-jahollari'] +--- + +## `linkedQueryParam` - Two-way binding for query parameters + +The `linkedQueryParam` utility function creates a signal that is linked to a query parameter in the URL. This allows you to easily keep your Angular application's state in sync with the URL, making it easier to share and bookmark specific views. + +Key Features: + +- **Two-way binding**: Changes to the signal are reflected in the URL, and changes to the URL are reflected in the signal. +- **Parsing and serialization**: You can provide functions to parse the query parameter value when reading it from the URL and serialize it when writing it to the URL. +- **Built-in parsers**: The library provides built-in parsers for common data types, such as numbers and booleans. +- **Default values**: You can provide a default value to be used if the query parameter is not present in the URL. +- **Coalesced updates**: Multiple updates to the signal within the same browser task are coalesced into a single URL update, improving performance. +- **Supports Navigation Extras**: The function supports navigation extras like `queryParamsHandling`, `onSameUrlNavigation`, `replaceUrl`, and `skipLocationChange`. +- **Testable**: The function is easy to test thanks to its reliance on Angular's dependency injection system. + +## Usage + +Here's a basic example of how to use `linkedQueryParam`: + +```ts +import { Component, inject } from '@angular/core'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; + +@Component({ + template: ` +

    Page: {{ currentPage() }}

    + + `, +}) +export class MyComponent { + readonly currentPage = linkedQueryParam('page', { + parse: (value) => parseInt(value ?? '1', 10), + }); +} +``` + +In this example, the page signal is linked to the page query parameter. The parse function ensures that the value is always a number, defaulting to 1 if the parameter is not present or cannot be parsed. Clicking the "Next Page" button increments the page signal, which in turn updates the URL to `/search?page=2`, `/search?page=3`, and so on. + +### Parsing and Serialization + +You can provide `parse` and `serialize` functions to transform the query parameter value between the URL and the signal. This is useful for handling different data types, such as booleans, numbers, and objects. + +```ts +export class MyCmp { + // Parse a boolean query parameter + showHidden = linkedQueryParam('showHidden', { + parse: (value) => value === 'true', + serialize: (value) => (value ? 'true' : 'false'), + }); + + // Parse a number query parameter + count = linkedQueryParam('count', { + parse: (value) => parseInt(value ?? '0', 10), + serialize: (value) => value.toString(), + }); + + // Parse an object query parameter + filter = linkedQueryParam('filter', { + parse: (value) => JSON.parse(value ?? '{}'), + serialize: (value) => JSON.stringify(value), + }); + + // Parse an array query parameter + selectedCategoriesIds = linkedQueryParam('selectedCategoriesIds', { + parse: (value) => value?.split(',').map((x) => x.trim()) ?? [], + serialize: (value) => value.join(','), + }); +} +``` + +NOTE: Make sure to put the `serialize` fn after the `parse` fn in order for types to work correctly. + +### Default Values + +You can provide a `defaultValue` to be used if the query parameter is not present in the URL. + +```ts +// Default to page 1 +page = linkedQueryParam('page', { + defaultValue: 1, +}); +``` + +Note: You cannot use both `defaultValue` and `parse` at the same time. If you need to parse the value and provide a default, use the parse function to handle both cases. + +### Built-in Parsers + +The `ngxtension` library provides some built-in parsers for common data types: + +- `paramToNumber`: Parses a number from a string. +- `paramToBoolean`: Parses a boolean from a string. + +```ts +export class MyDataComponent { + // Parse a number query parameter, defaults to null if the param is not available + readonly page = linkedQueryParam('page', { + parse: paramToNumber(), + }); + + readonly pageWithDefault = linkedQueryParam('page', { + // Default to 1 if the param is not available + parse: paramToNumber({ defaultValue: 1 }), + }); + + // Parse a boolean query parameter with a default value of false + readonly showHidden = linkedQueryParam('showHidden', { + parse: paramToBoolean(false), + }); +} +``` + +### Handling Null and Undefined Values + +The `linkedQueryParam` function handles null and undefined values gracefully. +If the query parameter is not present in the URL, the signal will be initialized with null. +You can also set the signal to null to remove the query parameter from the URL. + +### Navigation extras + +These will work the same as using them on the `navigate()` method of the Router. + +- `queryParamsHandling` + +You can specify how to handle query parameters when updating the URL. + +Options: + +- `merge` (default): default behavior that will merge current params with new ones +- `preserve`: won't update to the new params +- `''`: removes all other params except this one + +Example usage: + +```ts +page = linkedQueryParam('page', { + queryParamsHandling: '', +}); +``` + +- `skipLocationChange` + +When true, navigates without pushing a new state into history. +If you want to navigate back to the previous query param using back button or browser back button, this will break that feature, +because changes on the query params won't be registered in the browser history. + +```ts +page = linkedQueryParam('page', { + skipLocationChange: true, +}); +``` + +- `replaceUrl` + +You can specify whether to replace the current URL in the browser's history or push a new entry. + +```ts +const page = linkedQueryParam('page', { replaceUrl: true }); +``` + +### Examples of usage + +- Usage with template driven forms & resource API + +```ts +@Component({ + selector: 'app-todos', + template: ` +
    + + + + + + + + +
    + +

    Todos

    + + @if (todos.isLoading()) { +
    Loading...
    + } + @if (todos.error()) { +
    Error: {{ todos.error() }}
    + } +
      + @for (todo of todos.value(); track todo.id) { +
    • + + {{ todo.title }} +
    • + } +
    + `, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TodosComponent { + private http = inject(HttpClient); + + search = linkedQueryParam('search'); + status = linkedQueryParam('status'); + limit = linkedQueryParam('limit', { + parse: paramToNumber({ defaultValue: 10 }), + }); + page = linkedQueryParam('page', { + parse: paramToNumber({ defaultValue: 1 }), + }); + + todos = rxResource({ + request: () => ({ + search: this.search(), + status: this.status(), + limit: this.limit(), + page: this.page(), + }), + loader: ({ request }) => { + return this.http.get( + `https://jsonplaceholder.typicode.com/todos`, + { + params: { + _search: request.search ?? '', + _status: request.status ?? '', + _per_page: request.limit, + _page: request.page, + }, + }, + ); + }, + }); + + updateTodo(id: number, completed: boolean) { + this.todos.value.update((todos) => { + if (!todos) return []; + return todos.map((x) => ({ + ...x, + completed: x.id === id ? completed : x.completed, + })); + }); + } +} + +interface Todo { + id: number; + title: string; + completed: boolean; +} +``` diff --git a/libs/ngxtension/create-notifier/src/create-notifier.ts b/libs/ngxtension/create-notifier/src/create-notifier.ts index 73370cf7..ef87340d 100644 --- a/libs/ngxtension/create-notifier/src/create-notifier.ts +++ b/libs/ngxtension/create-notifier/src/create-notifier.ts @@ -1,5 +1,10 @@ import { signal } from '@angular/core'; +/** + * Creates a signal notifier that can be used to notify effects or other consumers. + * + * @returns A notifier object. + */ export function createNotifier() { const sourceSignal = signal(0); diff --git a/libs/ngxtension/linked-query-param/README.md b/libs/ngxtension/linked-query-param/README.md new file mode 100644 index 00000000..759fce81 --- /dev/null +++ b/libs/ngxtension/linked-query-param/README.md @@ -0,0 +1,3 @@ +# ngxtension/linked-query-param + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/linked-query-param`. diff --git a/libs/ngxtension/linked-query-param/ng-package.json b/libs/ngxtension/linked-query-param/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/linked-query-param/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/linked-query-param/project.json b/libs/ngxtension/linked-query-param/project.json new file mode 100644 index 00000000..30ff2dd0 --- /dev/null +++ b/libs/ngxtension/linked-query-param/project.json @@ -0,0 +1,27 @@ +{ + "name": "ngxtension/linked-query-param", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/linked-query-param/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["linked-query-param"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/linked-query-param/src/index.ts b/libs/ngxtension/linked-query-param/src/index.ts new file mode 100644 index 00000000..ebb710b7 --- /dev/null +++ b/libs/ngxtension/linked-query-param/src/index.ts @@ -0,0 +1 @@ +export * from './linked-query-param'; diff --git a/libs/ngxtension/linked-query-param/src/linked-query-param.spec.ts b/libs/ngxtension/linked-query-param/src/linked-query-param.spec.ts new file mode 100644 index 00000000..c1efeeb8 --- /dev/null +++ b/libs/ngxtension/linked-query-param/src/linked-query-param.spec.ts @@ -0,0 +1,508 @@ +import { + Component, + inject, + Injector, + numberAttribute, + OnInit, + signal, + WritableSignal, +} from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { + linkedQueryParam, + paramToBoolean, + paramToNumber, +} from './linked-query-param'; + +interface MyParams { + a: string; + b: number; + c: boolean; + d: { + e: string; + f: number; + g: boolean; + }; +} + +@Component({ standalone: true, template: `` }) +export class SearchComponent { + route = inject(ActivatedRoute); + + defaultBehavior = linkedQueryParam('defaultBehavior'); // WritableSignal + defaultBehaviorWithDefault = linkedQueryParam('defaultBehaviorWithDefault', { + defaultValue: 'default', + }); // WritableSignal + + parseBehavior = linkedQueryParam('parseBehavior', { + parse: (x: string | null) => parseInt(x ?? '0', 10), + }); // WritableSignal + + parseBehavior1 = linkedQueryParam('parseBehavior', { + parse: (x: string | null) => (x ? parseInt(x, 10) : x), + }); // WritableSignal + + parseBehaviorWithParseAndNumberAttribute = linkedQueryParam( + 'parseBehaviorWithParseAndNumberAttribute', + { parse: numberAttribute }, + ); // WritableSignal + + withBooleanParams = linkedQueryParam('withBooleanParams', { + parse: (x) => x === 'true', + serialize: (x) => (x ? 'true' : 'false'), + }); // WritableSignal + + withBuiltInBooleanParamNoDefault = linkedQueryParam('withBooleanNoDefault', { + parse: paramToBoolean(), + }); // WritableSignal + + withBuiltInBooleanParamWithDefault = linkedQueryParam( + 'withBooleanWithDefault', + { + parse: paramToBoolean({ defaultValue: true }), + }, + ); // WritableSignal + + withNumberParams = linkedQueryParam('withNumberParams', { + parse: (x: string | null) => { + if (x !== null) { + const parsed = parseInt(x, 10); + if (isNaN(parsed)) return null; + return parsed; + } + return x; + }, + serialize: (x) => x, + }); // WritableSignal + + withBuiltInNumberParamNoDefault = linkedQueryParam('withNumberNoDefault', { + parse: paramToNumber(), + }); // WritableSignal + + withBuiltInNumberParamWithDefault = linkedQueryParam( + 'withNumberWithDefault', + { + parse: paramToNumber({ defaultValue: 1 }), + }, + ); // WritableSignal + + withObjectParams = linkedQueryParam('withObjectParams', { + parse: (x: string | null) => (x ? JSON.parse(x) : x), + serialize: (x) => JSON.stringify(x), + }); // WritableSignal + + serializeBehavior = linkedQueryParam('serializeBehavior', { + serialize: (x) => x?.toUpperCase() ?? null, + }); // WritableSignal + + transformBehaviorWithDefault = linkedQueryParam( + 'transformBehaviorWithDefault', + { + serialize: (x: string | null) => x?.toUpperCase() ?? null, + defaultValue: 'default', + }, + ); +} + +describe(linkedQueryParam.name, () => { + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'search', component: SearchComponent }, + { + path: 'with-default-and-parse', + component: WithDefaultAndParseComponent, + }, + { path: 'with-injector-in-oninit', component: WithInjectorInOnInit }, + ]), + ], + }); + }); + + it('should create a signal linked to a query param', async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.defaultBehavior()).toBe(null); + expect(instance.defaultBehaviorWithDefault()).toBe('default'); + + await harness.navigateByUrl('/search?defaultBehavior=value'); + TestBed.flushEffects(); + expect(instance.defaultBehavior()).toBe('value'); + + await harness.navigateByUrl('/search?defaultBehaviorWithDefault=value'); + expect(instance.defaultBehaviorWithDefault()).toBe('value'); + + await harness.navigateByUrl('/search?defaultBehaviorWithDefault'); + expect(instance.defaultBehaviorWithDefault()).toBe(''); + }); + + it('should parse the query param value', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl( + '/search?parseBehavior=1', + SearchComponent, + ); + expect(instance.parseBehavior()).toBe(1); + + await harness.navigateByUrl('/search?parseBehavior=2'); + expect(instance.parseBehavior()).toBe(2); + + // update the value directly on the signal and it will be updated in the query param + instance.parseBehavior.set(3); + // it's synchronous here because we set the value directly on the signal + expect(instance.parseBehavior()).toBe(3); + + tick(); + expect(instance.route.snapshot.queryParams['parseBehavior']).toBe('3'); + + instance.parseBehavior.set(4); + expect(instance.parseBehavior()).toBe(4); + + tick(); + expect(instance.route.snapshot.queryParams['parseBehavior']).toBe('4'); + })); + + it('should parse with numberAttribute', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl( + '/search?parseBehaviorWithParseAndNumberAttribute=1', + SearchComponent, + ); + expect(instance.parseBehaviorWithParseAndNumberAttribute()).toBe(1); + await harness.navigateByUrl( + '/search?parseBehaviorWithParseAndNumberAttribute=2', + ); + expect(instance.parseBehaviorWithParseAndNumberAttribute()).toBe(2); + })); + + it('should throw error when using both parse and the default value', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + try { + await harness.navigateByUrl( + '/with-default-and-parse', + WithDefaultAndParseComponent, + ); + expect('this should not be reached').toBe(true); + } catch (e: any) { + expect(e.message).toBe( + 'linkedQueryParam: You cannot have both defaultValue and parse at the same time!', + ); + } + })); + + it('should transform the value we set on the signal', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl( + '/search?serializeBehavior=value', + SearchComponent, + ); + expect(instance.serializeBehavior()).toBe('value'); + + TestBed.flushEffects(); + expect(instance.route.snapshot.queryParams['serializeBehavior']).toBe( + 'value', + ); + + instance.serializeBehavior.set('value2'); + expect(instance.serializeBehavior()).toBe('value2'); + tick(); + expect(instance.route.snapshot.queryParams['serializeBehavior']).toBe( + 'VALUE2', + ); + + instance.serializeBehavior.set('value3'); + expect(instance.serializeBehavior()).toBe('value3'); + tick(); + expect(instance.route.snapshot.queryParams['serializeBehavior']).toBe( + 'VALUE3', + ); + + instance.serializeBehavior.set(null); + expect(instance.serializeBehavior()).toBe(null); + tick(); + expect(instance.route.snapshot.queryParams['serializeBehavior']).toBe( + undefined, + ); + })); + + it('should coalesce multiple set calls and only update the query param once', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + instance.defaultBehavior.set('value'); + expect(instance.defaultBehavior()).toBe('value'); + // no tick here yet + expect(instance.route.snapshot.queryParams['defaultBehavior']).toBe( + undefined, + ); + expect(instance.route.snapshot.queryParams['defaultBehavior']).not.toBe( + 'value', + ); + + // no tick here yet + instance.defaultBehavior.set('value2'); + expect(instance.defaultBehavior()).toBe('value2'); + + expect(instance.route.snapshot.queryParams['defaultBehavior']).toBe( + undefined, + ); + + tick(); + + expect(instance.route.snapshot.queryParams['defaultBehavior']).toBe( + 'value2', + ); + })); + + it('should handle null values', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + instance.defaultBehavior.set(null); + expect(instance.defaultBehavior()).toBe(null); + + tick(); + expect(instance.route.snapshot.queryParams['defaultBehavior']).toBe( + undefined, + ); + + await harness.navigateByUrl('/search?defaultBehaviorWithDefault=value1'); + expect(instance.defaultBehaviorWithDefault()).toBe('value1'); + + instance.defaultBehaviorWithDefault.set(null); + + expect(instance.defaultBehaviorWithDefault()).toBe(null); + + tick(); + expect( + instance.route.snapshot.queryParams['defaultBehaviorWithDefault'], + ).toBe(undefined); + })); + + it('should handle boolean values', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.withBooleanParams()).toBe(false); + + instance.withBooleanParams.set(true); + tick(); + expect(instance.withBooleanParams()).toBe(true); + expect(instance.route.snapshot.queryParams['withBooleanParams']).toBe( + 'true', + ); + + instance.withBooleanParams.set(false); + tick(); + expect(instance.withBooleanParams()).toBe(false); + expect(instance.route.snapshot.queryParams['withBooleanParams']).toBe( + 'false', + ); + + instance.withBooleanParams.set(null); + tick(); + expect(instance.withBooleanParams()).toBe(null); + expect(instance.route.snapshot.queryParams['withBooleanParams']).toBe( + 'false', + ); + })); + + it('should handle built-in boolean parser with no default', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.withBuiltInBooleanParamNoDefault()).toBe(null); + + instance.withBuiltInBooleanParamNoDefault.set(true); + tick(); + expect(instance.withBuiltInBooleanParamNoDefault()).toBe(true); + expect(instance.route.snapshot.queryParams['withBooleanNoDefault']).toBe( + 'true', + ); + + instance.withBuiltInBooleanParamNoDefault.set(false); + tick(); + expect(instance.withBuiltInBooleanParamNoDefault()).toBe(false); + expect(instance.route.snapshot.queryParams['withBooleanNoDefault']).toBe( + 'false', + ); + + instance.withBuiltInBooleanParamNoDefault.set(null); + tick(); + expect(instance.withBuiltInBooleanParamNoDefault()).toBe(null); + expect(instance.route.snapshot.queryParams['withBooleanNoDefault']).toBe( + undefined, + ); + })); + + it('should handle built-in boolean parser with default', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.withBuiltInBooleanParamWithDefault()).toBe(true); + expect(instance.route.snapshot.queryParams['withBooleanWithDefault']).toBe( + undefined, + ); + + instance.withBuiltInBooleanParamWithDefault.set(true); + tick(); + expect(instance.withBuiltInBooleanParamWithDefault()).toBe(true); + expect(instance.route.snapshot.queryParams['withBooleanWithDefault']).toBe( + 'true', + ); + + instance.withBuiltInBooleanParamWithDefault.set(false); + tick(); + expect(instance.withBuiltInBooleanParamWithDefault()).toBe(false); + expect(instance.route.snapshot.queryParams['withBooleanWithDefault']).toBe( + 'false', + ); + + instance.withBuiltInBooleanParamWithDefault.set(null); + tick(); + expect(instance.withBuiltInBooleanParamWithDefault()).toBe(true); + expect(instance.route.snapshot.queryParams['withBooleanWithDefault']).toBe( + undefined, + ); + })); + + it('should handle number values', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.withNumberParams()).toBe(null); + + instance.withNumberParams.set(1); + tick(); + expect(instance.withNumberParams()).toBe(1); + expect(instance.route.snapshot.queryParams['withNumberParams']).toBe('1'); + + instance.withNumberParams.set(null); + tick(); + expect(instance.withNumberParams()).toBe(null); + expect(instance.route.snapshot.queryParams['withNumberParams']).toBe( + undefined, + ); + })); + + it('should handle built-in number parser with no default', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.withBuiltInNumberParamNoDefault()).toBe(null); + expect(instance.route.snapshot.queryParams['withNumberNoDefault']).toBe( + undefined, + ); + + instance.withBuiltInNumberParamNoDefault.set(1); + tick(); + expect(instance.withBuiltInNumberParamNoDefault()).toBe(1); + expect(instance.route.snapshot.queryParams['withNumberNoDefault']).toBe( + '1', + ); + + instance.withBuiltInNumberParamNoDefault.set(2); + tick(); + expect(instance.withBuiltInNumberParamNoDefault()).toBe(2); + expect(instance.route.snapshot.queryParams['withNumberNoDefault']).toBe( + '2', + ); + + instance.withBuiltInNumberParamNoDefault.set(null); + tick(); + expect(instance.withBuiltInNumberParamNoDefault()).toBe(null); + expect(instance.route.snapshot.queryParams['withNumberNoDefault']).toBe( + undefined, + ); + })); + + it('should handle built-in number parser with default', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl('/search', SearchComponent); + + expect(instance.withBuiltInNumberParamWithDefault()).toBe(1); + expect(instance.route.snapshot.queryParams['withNumberWithDefault']).toBe( + undefined, + ); + + instance.withBuiltInNumberParamWithDefault.set(1); + tick(); + expect(instance.withBuiltInNumberParamWithDefault()).toBe(1); + expect(instance.route.snapshot.queryParams['withNumberWithDefault']).toBe( + '1', + ); + + instance.withBuiltInNumberParamWithDefault.set(2); + tick(); + expect(instance.withBuiltInNumberParamWithDefault()).toBe(2); + expect(instance.route.snapshot.queryParams['withNumberWithDefault']).toBe( + '2', + ); + + instance.withBuiltInNumberParamWithDefault.set(null); + tick(); + expect(instance.withBuiltInNumberParamWithDefault()).toBe(1); + expect(instance.route.snapshot.queryParams['withNumberWithDefault']).toBe( + undefined, + ); + })); + + it('should work with injector in ngOnInit', fakeAsync(async () => { + const harness = await RouterTestingHarness.create(); + const instance = await harness.navigateByUrl( + '/with-injector-in-oninit', + WithInjectorInOnInit, + ); + + instance.ngOnInit(); + tick(); + + expect(instance.param()).toBe(null); + expect(instance.route.snapshot.queryParams['testParamInit']).toBe( + undefined, + ); + + instance.param.set('1'); + tick(); + expect(instance.param()).toBe('1'); + expect(instance.route.snapshot.queryParams['testParamInit']).toBe('1'); + + instance.param.set(null); + tick(); + expect(instance.param()).toBe(null); + expect(instance.route.snapshot.queryParams['testParamInit']).toBe( + undefined, + ); + })); +}); + +@Component({ standalone: true, template: `` }) +export class WithInjectorInOnInit implements OnInit { + private injector = inject(Injector); + route = inject(ActivatedRoute); + + param!: WritableSignal; + + ngOnInit() { + this.param = linkedQueryParam('testParamInit', { injector: this.injector }); + } +} + +@Component({ standalone: true, template: `` }) +export class WithDefaultAndParseComponent { + route = inject(ActivatedRoute); + parseBehaviorWithDefault = linkedQueryParam('parseBehaviorWithDefault', { + parse: (x: any) => (x ? parseInt(x, 10) : x), + defaultValue: 1, + }); // never + + testTypes() { + // @ts-expect-error Type 'never' is not assignable to type 'WritableSignal'. + this.parseBehaviorWithDefault = signal(1); + } +} diff --git a/libs/ngxtension/linked-query-param/src/linked-query-param.ts b/libs/ngxtension/linked-query-param/src/linked-query-param.ts new file mode 100644 index 00000000..893509f0 --- /dev/null +++ b/libs/ngxtension/linked-query-param/src/linked-query-param.ts @@ -0,0 +1,414 @@ +import { + effect, + inject, + Injectable, + Injector, + runInInjectionContext, + signal, + untracked, + WritableSignal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + NavigationExtras, + Params, + Router, +} from '@angular/router'; +import { assertInjector } from 'ngxtension/assert-injector'; +import { createNotifier } from 'ngxtension/create-notifier'; +import { distinctUntilKeyChanged, map } from 'rxjs'; + +/** + * The type of the serialized value. + * After transforming the value before it is passed to the query param, this type will be used. + */ +type SerializeReturnType = string | number | boolean | null | undefined; + +/** + * These are the options that can be passed to the `linkedQueryParam` function. + * They are taken from the `NavigationExtras` type in the `@angular/router` package. + */ +type NavigateMethodFields = Pick< + NavigationExtras, + | 'queryParamsHandling' + | 'onSameUrlNavigation' + | 'replaceUrl' + | 'skipLocationChange' +>; + +/** + * Service to coalesce multiple navigation calls into a single navigation event. + */ +@Injectable({ providedIn: 'root' }) +export class LinkedQueryParamGlobalHandler { + private router = inject(Router); + /** + * @internal + * The current query params that will be set on the next navigation event. + */ + private _currentKeys: Record = {}; + /** + * @internal + * The navigation extras that will be used on the next navigation event. + */ + private _navigationExtras: NavigationExtras = {}; + /** + * @internal + * The notifier that will be used to schedule the navigation event. + */ + _schedulerNotifier = createNotifier(); + + constructor() { + effect(() => { + // listen to the scheduler notifier to schedule the navigation event + this._schedulerNotifier.listen(); + + // we need to untrack the navigation call in order to not register any other signal as a dependency + untracked(() => this.navigate().then()); + }); + } + + /** + * Sets the value of a query param. + * This will be used on the next navigation event. + */ + setParamKeyValue(key: string, value: SerializeReturnType) { + this._currentKeys[key] = value; + } + + /** + * Sets the navigation extras that will be used on the next navigation event. + */ + setCurrentNavigationExtras(config: Partial) { + const { + queryParamsHandling, + onSameUrlNavigation, + replaceUrl, + skipLocationChange, + } = config ?? {}; + + if (queryParamsHandling || queryParamsHandling === '') + this._navigationExtras.queryParamsHandling = queryParamsHandling; + if (onSameUrlNavigation) + this._navigationExtras.onSameUrlNavigation = onSameUrlNavigation; + if (replaceUrl) this._navigationExtras.replaceUrl = replaceUrl; + if (skipLocationChange) + this._navigationExtras.skipLocationChange = skipLocationChange; + } + + /** + * Navigates to the current URL with the accumulated query parameters and navigation extras. + * Cleans up the current keys and navigation extras after the navigation. + */ + private navigate(): Promise { + return this.router + .navigate([], { + queryParams: this._currentKeys, + queryParamsHandling: 'merge', // can be overridden by the `queryParamsHandling` option + ...this._navigationExtras, // override the navigation extras + }) + .then((value) => { + // we reset the current keys and navigation extras on navigation + // in order to avoid leaking to other navigations + this._currentKeys = {}; + this._navigationExtras = {}; + return value; + }); + } +} + +type LinkedQueryParamOptions = { + /** + * The injector to use to inject the router and activated route. + */ + injector?: Injector; +} & Partial; + +/** + * These are the function types that will be used to parse and serialize the query param value. + */ +type ParseFn = (value: string | null) => T; +type SerializeFn = (value: T) => string | number | null; + +/** + *These types will be used to define the return types of the `set` and `update` methods of the signal. + * We need to re-type the WritableSignal, so that the set and update methods can have null in the call signature. + * But the WritableSignal itself won't have null in the call signature, so we need to re-type it. + * This is needed in order to be able to reset the value to null, + * which is not possible with the WritableSignal that doesn't have null in it's type. + */ +type SignalSetFn = (value: T) => void; +type SignalUpdateFn = (fn: (value: T) => T) => void; + +/** + * Creates a signal that is linked to a query parameter. + * + * You can parse the query param value before it is passed to the signal, this way you can transform the value from a string to a number or boolean or whatever you need. + * You can also serialize the value before it is passed to the query param, this way you can serialize the value from a number or boolean or object to a string or null. + * + * You can also use the `defaultValue` option to set a default value if the query param is not present in the url (null or undefined). + * NOTE: You cannot use both `defaultValue` and `parse` at the same time. You should use `parse` instead to handle the default value. + * + * You can set the signal to update the query parameter by calling the `set` or `update` method. + * Both methods will accept the value + null as a valid value, so you can remove the query parameter by passing null if needed. + * + * The 'set' and 'update' methods will update the value synchronously, but will schedule the navigation event to + * happen on the next tick (using root effect scheduling). This means the query params will be updated asynchronously. + * The changes will be coalesced into a single navigation event. This means that if you call `set` or `update` multiple times + * in a row (synchronously), only the last value will be updated in the query params. + * + * If you have multiple signals listening to the same query parameter, they will all be updated when the navigation event happens. + * + * @param key The name of the query parameter. + * @param options Configuration options for the signal. + * @returns A signal that is linked to the query parameter. + */ +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { + parse: ParseFn; + serialize: SerializeFn; + }, +): WritableSignal & { + set: SignalSetFn; + update: SignalUpdateFn; +}; + +/** + * You cannot use both `defaultValue` and `parse` at the same time. + * You should use `parse` instead to handle the default value. + * + * For example, you cannot do this: + * + * ```ts + * linkedQueryParam('param', { defaultValue: 1, parse: (x) => x ? parseInt(x, 10) : x }); + * ``` + * + * Instead, you should do this: + * + * ```ts + * linkedQueryParam('param', { parse: (x) => x ? parseInt(x, 10) : 1 }); + * ``` + */ +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { + defaultValue: Exclude; + parse: ParseFn; + serialize?: SerializeFn; + }, +): never; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { + defaultValue: T; + serialize: SerializeFn; + }, +): WritableSignal; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { defaultValue: T }, +): WritableSignal & { + set: SignalSetFn; + update: SignalUpdateFn; +}; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { defaultValue: T | null }, +): WritableSignal; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { defaultValue: T | undefined }, +): WritableSignal; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { defaultValue: undefined }, +): WritableSignal; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { parse: ParseFn }, +): WritableSignal & { + set: SignalSetFn; + update: SignalUpdateFn; +}; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions & { serialize: SerializeFn }, +): WritableSignal; + +export function linkedQueryParam( + key: string, + options: LinkedQueryParamOptions, +): WritableSignal; + +export function linkedQueryParam( + key: string, +): WritableSignal; + +export function linkedQueryParam( + key: string, + options?: LinkedQueryParamOptions & { + defaultValue?: T; + parse?: ParseFn; + serialize?: SerializeFn; + }, +): WritableSignal { + const injector = assertInjector(linkedQueryParam, options?.injector); + + if (options?.defaultValue !== undefined && options?.parse) { + throw new Error( + 'linkedQueryParam: You cannot have both defaultValue and parse at the same time!', + ); + } + + return runInInjectionContext(injector, () => { + const route = inject(ActivatedRoute); + const globalHandler = inject(LinkedQueryParamGlobalHandler); + + /** + * Parses a parameter value based on provided configuration. + * @param params - An object containing parameters. + * @returns The parsed parameter value. + */ + const parseParamValue = (params: Params) => { + // Get the value from the params object. + let value: string | null = params[key] ?? null; + // If a parsing function is provided in the config, use it to parse the value. + if (options?.parse) { + return options.parse(value); + } + // If the value is undefined or null and a default value is provided, return the default value. + if ( + (value === undefined || value === null) && + options?.defaultValue !== undefined + ) { + return options.defaultValue; + } + // Otherwise, return the original value or the parsed value (if it was parsed). + return value; + }; + + // create a signal that is updated whenever the query param changes + const queryParamValue = toSignal( + route.queryParams.pipe( + distinctUntilKeyChanged(key), // skip if no changes on same key + map((x) => parseParamValue(x)), + ), + { initialValue: parseParamValue(route.snapshot.queryParams) }, + ); + + const source = signal(queryParamValue() as T); + + const originalSet = source.set; + + effect(() => { + const x = queryParamValue(); + // update the source signal whenever the query param changes + untracked(() => originalSet(x as T)); + }); + + const set = (value: T) => { + // we first set the initial value so it synchronous (same as a normal signal) + originalSet(value); + + // when the source signal changes, update the query param + // store the new value in the current keys so that we can coalesce the navigation + let valueToBeSet: any = value; + if (options?.serialize) { + valueToBeSet = options.serialize(value); + } else if (value === undefined || value === null) { + valueToBeSet = null; + } else { + valueToBeSet = typeof value === 'string' ? value : String(value); + } + + globalHandler.setParamKeyValue(key, valueToBeSet); + globalHandler.setCurrentNavigationExtras(options ?? {}); + + // schedule the navigation event (multiple synchronous navigations will be coalesced) + // this will also reset the current keys and navigation extras after the navigation + globalHandler._schedulerNotifier.notify(); + }; + + const update = (fn: (value: T) => T) => set(fn(source())); + + return Object.assign(source, { set, update }); + }); +} + +/** + * Can be used to parse a query param value to a number. + * You can also use the `defaultValue` option to set a default value if the query param is not present in the url (null or undefined). + * + * Example: + * ```ts + * linkedQueryParam('page', { parse: paramToNumber() }); + * ``` + * Will return null if the query param is not present in the url. + * + * Or with a default value: + * ```ts + * linkedQueryParam('page', { parse: paramToNumber({defaultValue: 1}) }); + * ``` + * + * Will return 1 if the query param is not present in the url. + */ +export function paramToNumber(config: { + defaultValue: number; +}): (x: string | null) => number; +export function paramToNumber(config?: { + defaultValue?: number | null | undefined; +}): (x: string | null) => number | null; + +export function paramToNumber( + config: { defaultValue?: number | null | undefined } = { defaultValue: null }, +) { + return (x: string | null) => { + if (x === undefined || x === null) return config.defaultValue; + const parsed = parseInt(x, 10); + if (Number.isNaN(parsed)) return config.defaultValue; + return parsed; + }; +} + +/** + * Can be used to parse a query param value to a boolean. + * You can also use the `defaultValue` option to set a default value if the query param is not present in the url (null or undefined). + * + * Example: + * ```ts + * linkedQueryParam('showHidden', { parse: paramToBoolean() }); + * ``` + * Will return null if the query param is not present in the url or true/false if the query param is present. + * + * Or with a default value: + * ```ts + * linkedQueryParam('showHidden', { parse: paramToBoolean({defaultValue: true}) }); + * ``` + * + * Will return true if the query param is not present in the url. + * Otherwise, it will return whatever the query param value is. + */ +export function paramToBoolean(config: { + defaultValue: boolean; +}): (x: string | null) => boolean; +export function paramToBoolean(config?: { + defaultValue?: boolean | null | undefined; +}): (x: string | null) => boolean | null; + +export function paramToBoolean( + config: { defaultValue?: boolean | null | undefined } = { + defaultValue: null, + }, +) { + return (x: string | null) => + x === undefined || x === null ? config.defaultValue : x === 'true'; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 98cd8caa..10c1f219 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -112,6 +112,9 @@ "libs/ngxtension/inject-route-fragment/src/index.ts" ], "ngxtension/intl": ["libs/ngxtension/intl/src/index.ts"], + "ngxtension/linked-query-param": [ + "libs/ngxtension/linked-query-param/src/index.ts" + ], "ngxtension/map-array": ["libs/ngxtension/map-array/src/index.ts"], "ngxtension/map-skip-undefined": [ "libs/ngxtension/map-skip-undefined/src/index.ts"