From 428e266f950f15dea079dfd786755da15f91b940 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ingo=20B=C3=BCrk?=
Date: Fri, 12 Apr 2019 13:45:05 +0200
Subject: [PATCH] feat(core): support asynchronous deserializers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We now support deserializers which run asynchronously by returning an
observable instead. This is useful when the deserializer relies on some
asynchronous process or data which has to be fetched first.
relates #93
Signed-off-by: Ingo Bürk
---
...ry-param-configuration-docs.component.html | 12 +++
.../ngqp-demo/src/app/home/home.component.ts | 2 +-
.../directives/query-param-group.service.ts | 73 ++++++++++++-------
.../ngqp/core/src/lib/model/query-param.ts | 27 +++----
projects/ngqp/core/src/lib/types.ts | 3 +-
projects/ngqp/core/src/lib/util.spec.ts | 25 +++++++
projects/ngqp/core/src/lib/util.ts | 10 +++
.../src/test/serialize-deserialize.spec.ts | 68 +++++++++++++++++
8 files changed, 175 insertions(+), 45 deletions(-)
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..46e6528 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: `
+