diff --git a/projects/ngqp-demo/src/app/docs-items/configuration/query-param/query-param-configuration-docs.component.html b/projects/ngqp-demo/src/app/docs-items/configuration/query-param/query-param-configuration-docs.component.html index 84aca32..53dd155 100644 --- a/projects/ngqp-demo/src/app/docs-items/configuration/query-param/query-param-configuration-docs.component.html +++ b/projects/ngqp-demo/src/app/docs-items/configuration/query-param/query-param-configuration-docs.component.html @@ -20,6 +20,18 @@

Serializing and deserializing

provide your own (de-)serializers, you can pass them to QueryParamBuilder#param.

+
+

+ Deserializers can also be asynchronous by returning an Observable instead. This can be useful, e.g., if you + have a user's name in the URL and need to deserialize it into a user object, but fetching the list of users + is an asynchronous operation. +

+

+ Note that for asynchronous deserializers, ngqp will use the first emitted value rather than the + last one. Furthermore, the valueChanges observables will only emit after the deserializer has + emitted. +

+

Components with multiple values

diff --git a/projects/ngqp-demo/src/app/home/home.component.ts b/projects/ngqp-demo/src/app/home/home.component.ts index 43aed8e..82ccef9 100644 --- a/projects/ngqp-demo/src/app/home/home.component.ts +++ b/projects/ngqp-demo/src/app/home/home.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { createStringDeserializer, QueryParamBuilder, QueryParamGroup } from '@ngqp/core'; +import { QueryParamBuilder, QueryParamGroup } from '@ngqp/core'; import { faAlignLeft, faCogs, faGlassCheers, faHeart, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons'; diff --git a/projects/ngqp/core/src/lib/directives/query-param-group.service.ts b/projects/ngqp/core/src/lib/directives/query-param-group.service.ts index c512343..4ff692f 100644 --- a/projects/ngqp/core/src/lib/directives/query-param-group.service.ts +++ b/projects/ngqp/core/src/lib/directives/query-param-group.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, isDevMode, OnDestroy, Optional } from '@angular/core'; import { Params } from '@angular/router'; -import { EMPTY, from, Observable, Subject, zip } from 'rxjs'; +import { EMPTY, forkJoin, from, Observable, Subject, zip } from 'rxjs'; import { catchError, concatMap, @@ -15,7 +15,7 @@ import { } from 'rxjs/operators'; import { compareParamMaps, filterParamMap, isMissing, isPresent, NOP } from '../util'; import { QueryParamGroup } from '../model/query-param-group'; -import { MultiQueryParam, QueryParam, PartitionedQueryParam } from '../model/query-param'; +import { MultiQueryParam, PartitionedQueryParam, QueryParam } from '../model/query-param'; import { NGQP_ROUTER_ADAPTER, NGQP_ROUTER_OPTIONS, RouterAdapter, RouterOptions } from '../router-adapter/router-adapter.interface'; import { QueryParamAccessor } from './query-param-accessor.interface'; @@ -226,32 +226,49 @@ export class QueryParamGroupService implements OnDestroy { return compareParamMaps(filterParamMap(previousMap, keys), filterParamMap(currentMap, keys)); }), )), - takeUntil(this.destroy$), - ).subscribe(queryParamMap => { - const synthetic = this.isSyntheticNavigation(); - const groupValue: Record = {}; - - Object.keys(this.getQueryParamGroup().queryParams).forEach(queryParamName => { - const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName); - const newValues = partitionedQueryParam.queryParams.map(queryParam => isMultiQueryParam(queryParam) - ? queryParam.deserializeValue(queryParamMap.getAll(queryParam.urlParam)) - : queryParam.deserializeValue(queryParamMap.get(queryParam.urlParam)) + switchMap(queryParamMap => { + // We need to capture this right here since this is only set during the on-going navigation. + const synthetic = this.isSyntheticNavigation(); + const queryParamNames = Object.keys(this.getQueryParamGroup().queryParams); + + return forkJoin>(...queryParamNames + .map(queryParamName => { + const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName); + + return forkJoin(...partitionedQueryParam.queryParams + .map(queryParam => isMultiQueryParam(queryParam) + ? queryParam.deserializeValue(queryParamMap.getAll(queryParam.urlParam)) + : queryParam.deserializeValue(queryParamMap.get(queryParam.urlParam)) + ) + ).pipe( + map(newValues => partitionedQueryParam.reduce(newValues)), + tap(newValue => { + const directives = this.directives.get(queryParamName); + if (directives) { + directives.forEach(directive => directive.valueAccessor.writeValue(newValue)); + } + }), + map(newValue => { + return { [ queryParamName ]: newValue }; + }), + takeUntil(this.destroy$), + ); + }) + ).pipe( + map((values: Record[]) => values.reduce((groupValue, value) => { + return { + ...groupValue, + ...value, + }; + }, {})), + tap(groupValue => this.getQueryParamGroup().setValue(groupValue, { + emitEvent: !synthetic, + emitModelToViewChange: false, + })), ); - const newValue = partitionedQueryParam.reduce(newValues); - - const directives = this.directives.get(queryParamName); - if (directives) { - directives.forEach(directive => directive.valueAccessor.writeValue(newValue)); - } - - groupValue[ queryParamName ] = newValue; - }); - - this.getQueryParamGroup().setValue(groupValue, { - emitEvent: !synthetic, - emitModelToViewChange: false, - }); - }); + }), + takeUntil(this.destroy$), + ).subscribe(); } /** Listens for newly added parameters and starts synchronization for them. */ @@ -350,7 +367,7 @@ export class QueryParamGroupService implements OnDestroy { return { ...(this.globalRouterOptions || {}), - ...groupOptions, + ...(groupOptions || {}), }; } diff --git a/projects/ngqp/core/src/lib/model/query-param.ts b/projects/ngqp/core/src/lib/model/query-param.ts index 37b33b7..2d74214 100644 --- a/projects/ngqp/core/src/lib/model/query-param.ts +++ b/projects/ngqp/core/src/lib/model/query-param.ts @@ -1,5 +1,6 @@ -import { Observable, Subject } from 'rxjs'; -import { areEqualUsing, isFunction, isMissing, isPresent, undefinedToNull, wrapTryCatch } from '../util'; +import { forkJoin, isObservable, Observable, of, Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { areEqualUsing, isFunction, isMissing, isPresent, undefinedToNull, wrapIntoObservable, wrapTryCatch } from '../util'; import { Comparator, OnChangeFunction, ParamCombinator, ParamDeserializer, ParamSerializer, Partitioner, Reducer } from '../types'; import { QueryParamGroup } from './query-param-group'; import { MultiQueryParamOpts, PartitionedQueryParamOpts, QueryParamOpts, QueryParamOptsBase } from './query-param-opts'; @@ -117,12 +118,6 @@ export abstract class AbstractQueryParam extends AbstractQueryParamBase this.combineWith = combineWith; } - /** @internal */ - public abstract serializeValue(value: T | null): (string | null) | (string | null)[]; - - /** @internal */ - public abstract deserializeValue(value: (string | null) | (string | null)[]): T | null; - /** * Updates the value of this parameter. * @@ -180,12 +175,12 @@ export class QueryParam extends AbstractQueryParam implem } /** @internal */ - public deserializeValue(value: string | null): T | null { + public deserializeValue(value: string | null): Observable { if (this.emptyOn !== undefined && value === null) { - return this.emptyOn; + return of(this.emptyOn); } - return this.deserialize(value); + return wrapIntoObservable(this.deserialize(value)).pipe(first()); } } @@ -212,12 +207,14 @@ export class MultiQueryParam extends AbstractQueryParam { + if (this.emptyOn !== undefined && (values || []).length === 0) { + return of(this.emptyOn); } - return (value || []).map(this.deserialize.bind(this)); + return forkJoin(...(values || []) + .map(value => wrapIntoObservable(this.deserialize(value)).pipe(first())) + ); } } diff --git a/projects/ngqp/core/src/lib/types.ts b/projects/ngqp/core/src/lib/types.ts index 1a168b9..1404782 100644 --- a/projects/ngqp/core/src/lib/types.ts +++ b/projects/ngqp/core/src/lib/types.ts @@ -1,4 +1,5 @@ import { Params } from '@angular/router'; +import { Observable } from 'rxjs'; /** * A serializer defines how the represented form control's @@ -10,7 +11,7 @@ export type ParamSerializer = (model: T | null) => string | null; * A deserializer defines how a URL parameter is converted * into the represented form control's value. */ -export type ParamDeserializer = (value: string | null) => T | null; +export type ParamDeserializer = (value: string | null) => (T | null) | Observable; /** * A partitioner can split a value into an array of parts. diff --git a/projects/ngqp/core/src/lib/util.spec.ts b/projects/ngqp/core/src/lib/util.spec.ts index 7a600b3..bb2899f 100644 --- a/projects/ngqp/core/src/lib/util.spec.ts +++ b/projects/ngqp/core/src/lib/util.spec.ts @@ -1,4 +1,5 @@ import { convertToParamMap, Params } from '@angular/router'; +import { of } from 'rxjs'; import { areEqualUsing, compareParamMaps, @@ -9,6 +10,7 @@ import { isPresent, LOOSE_IDENTITY_COMPARATOR, undefinedToNull, + wrapIntoObservable, wrapTryCatch } from './util'; import { Comparator } from './types'; @@ -266,4 +268,27 @@ describe(compareParamMaps.name, () => { { a: [ 'a2', 'a1' ] }, )).toBe(true); }); +}); + +describe(wrapIntoObservable.name, async () => { + it('wraps null', async () => { + const obs$ = wrapIntoObservable(null); + expect(obs$).toBeDefined(); + + obs$.subscribe(v => expect(v).toBe(null)); + }); + + it('wraps a primitive value', async () => { + const obs$ = wrapIntoObservable(42); + expect(obs$).toBeDefined(); + + obs$.subscribe(v => expect(v).toBe(42)); + }); + + it('does not wrap an observable', async () => { + const obs$ = wrapIntoObservable(of(42)); + expect(obs$).toBeDefined(); + + obs$.subscribe(v => expect(v).toBe(42)); + }); }); \ No newline at end of file diff --git a/projects/ngqp/core/src/lib/util.ts b/projects/ngqp/core/src/lib/util.ts index 2509765..81de7cd 100644 --- a/projects/ngqp/core/src/lib/util.ts +++ b/projects/ngqp/core/src/lib/util.ts @@ -1,4 +1,5 @@ import { convertToParamMap, ParamMap, Params } from '@angular/router'; +import { isObservable, Observable, of } from 'rxjs'; import { Comparator } from './types'; /** @internal */ @@ -96,4 +97,13 @@ export function compareStringArraysUnordered(first: string[], second: string[]): const sortedFirst = first.sort(); const sortedSecond = second.sort(); return sortedFirst.every((firstKey, index) => firstKey === sortedSecond[index]); +} + +/** @internal */ +export function wrapIntoObservable(input: T | Observable): Observable { + if (isObservable(input)) { + return input; + } + + return of(input); } \ No newline at end of file diff --git a/projects/ngqp/core/src/test/serialize-deserialize.spec.ts b/projects/ngqp/core/src/test/serialize-deserialize.spec.ts index dd3de97..eb62535 100644 --- a/projects/ngqp/core/src/test/serialize-deserialize.spec.ts +++ b/projects/ngqp/core/src/test/serialize-deserialize.spec.ts @@ -2,6 +2,8 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { delay } from 'rxjs/operators'; import { QueryParamBuilder, QueryParamGroup, QueryParamModule } from '../public_api'; import { setupNavigationWarnStub } from './util'; @@ -27,6 +29,28 @@ class BasicTestComponent { } +@Component({ + template: ` +
+ +
+ `, +}) +class AsyncTestComponent { + + public paramGroup: QueryParamGroup; + + constructor(private qpb: QueryParamBuilder) { + this.paramGroup = qpb.group({ + param: qpb.stringParam('q', { + serialize: value => value ? value.toUpperCase() : null, + deserialize: value => of(value ? value.toLowerCase() : null).pipe(delay(500)), + }), + }); + } + +} + describe('(de-)serialize', () => { let fixture: ComponentFixture; let component: BasicTestComponent; @@ -72,6 +96,50 @@ describe('(de-)serialize', () => { router.navigateByUrl('/?q=TEST'); tick(); + expect(input.value).toBe('test'); + })); +}); + +describe('asynchronous (de-)serialize', () => { + let fixture: ComponentFixture; + let component: AsyncTestComponent; + let input: HTMLInputElement; + let router: Router; + + beforeEach(() => setupNavigationWarnStub()); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + QueryParamModule, + ], + declarations: [ + AsyncTestComponent, + ], + }); + + router = TestBed.get(Router); + TestBed.compileComponents(); + router.initialNavigation(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AsyncTestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + input = (fixture.nativeElement as HTMLElement).querySelector('input') as HTMLInputElement; + fixture.detectChanges(); + }); + + it('applies the async deserializer when the URL changes', fakeAsync(() => { + router.navigateByUrl('/?q=TEST'); + + tick(); + expect(input.value).toBe(''); + + tick(500); expect(input.value).toBe('test'); })); }); \ No newline at end of file