Skip to content

Commit 428e266

Browse files
committed
feat(core): support asynchronous deserializers
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 <ingo.buerk@tngtech.com>
1 parent 768516c commit 428e266

8 files changed

Lines changed: 175 additions & 45 deletions

File tree

projects/ngqp-demo/src/app/docs-items/configuration/query-param/query-param-configuration-docs.component.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ <h2>Serializing and deserializing</h2>
2020
provide your own (de-)serializers, you can pass them to <span apiDocsLink>QueryParamBuilder#param</span>.
2121
</p>
2222
<demo-serializer-example></demo-serializer-example>
23+
<div class="alert alert-info mt-3">
24+
<p>
25+
Deserializers can also be asynchronous by returning an Observable instead. This can be useful, e.g., if you
26+
have a user's name in the URL and need to deserialize it into a user object, but fetching the list of users
27+
is an asynchronous operation.
28+
</p>
29+
<p>
30+
Note that for asynchronous deserializers, ngqp will use the <em>first</em> emitted value rather than the
31+
last one. Furthermore, the <code>valueChanges</code> observables will only emit after the deserializer has
32+
emitted.
33+
</p>
34+
</div>
2335

2436
<docs-fragment fragment="multi">
2537
<h2>Components with multiple values</h2>

projects/ngqp-demo/src/app/home/home.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component } from '@angular/core';
2-
import { createStringDeserializer, QueryParamBuilder, QueryParamGroup } from '@ngqp/core';
2+
import { QueryParamBuilder, QueryParamGroup } from '@ngqp/core';
33
import { faAlignLeft, faCogs, faGlassCheers, faHeart, IconDefinition } from '@fortawesome/free-solid-svg-icons';
44
import { faGithub } from '@fortawesome/free-brands-svg-icons';
55

projects/ngqp/core/src/lib/directives/query-param-group.service.ts

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Inject, Injectable, isDevMode, OnDestroy, Optional } from '@angular/core';
22
import { Params } from '@angular/router';
3-
import { EMPTY, from, Observable, Subject, zip } from 'rxjs';
3+
import { EMPTY, forkJoin, from, Observable, Subject, zip } from 'rxjs';
44
import {
55
catchError,
66
concatMap,
@@ -15,7 +15,7 @@ import {
1515
} from 'rxjs/operators';
1616
import { compareParamMaps, filterParamMap, isMissing, isPresent, NOP } from '../util';
1717
import { QueryParamGroup } from '../model/query-param-group';
18-
import { MultiQueryParam, QueryParam, PartitionedQueryParam } from '../model/query-param';
18+
import { MultiQueryParam, PartitionedQueryParam, QueryParam } from '../model/query-param';
1919
import { NGQP_ROUTER_ADAPTER, NGQP_ROUTER_OPTIONS, RouterAdapter, RouterOptions } from '../router-adapter/router-adapter.interface';
2020
import { QueryParamAccessor } from './query-param-accessor.interface';
2121

@@ -226,32 +226,49 @@ export class QueryParamGroupService implements OnDestroy {
226226
return compareParamMaps(filterParamMap(previousMap, keys), filterParamMap(currentMap, keys));
227227
}),
228228
)),
229-
takeUntil(this.destroy$),
230-
).subscribe(queryParamMap => {
231-
const synthetic = this.isSyntheticNavigation();
232-
const groupValue: Record<string, unknown> = {};
233-
234-
Object.keys(this.getQueryParamGroup().queryParams).forEach(queryParamName => {
235-
const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName);
236-
const newValues = partitionedQueryParam.queryParams.map(queryParam => isMultiQueryParam<unknown>(queryParam)
237-
? queryParam.deserializeValue(queryParamMap.getAll(queryParam.urlParam))
238-
: queryParam.deserializeValue(queryParamMap.get(queryParam.urlParam))
229+
switchMap(queryParamMap => {
230+
// We need to capture this right here since this is only set during the on-going navigation.
231+
const synthetic = this.isSyntheticNavigation();
232+
const queryParamNames = Object.keys(this.getQueryParamGroup().queryParams);
233+
234+
return forkJoin<Record<string, unknown>>(...queryParamNames
235+
.map(queryParamName => {
236+
const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName);
237+
238+
return forkJoin<unknown>(...partitionedQueryParam.queryParams
239+
.map(queryParam => isMultiQueryParam<unknown>(queryParam)
240+
? queryParam.deserializeValue(queryParamMap.getAll(queryParam.urlParam))
241+
: queryParam.deserializeValue(queryParamMap.get(queryParam.urlParam))
242+
)
243+
).pipe(
244+
map(newValues => partitionedQueryParam.reduce(newValues)),
245+
tap(newValue => {
246+
const directives = this.directives.get(queryParamName);
247+
if (directives) {
248+
directives.forEach(directive => directive.valueAccessor.writeValue(newValue));
249+
}
250+
}),
251+
map(newValue => {
252+
return { [ queryParamName ]: newValue };
253+
}),
254+
takeUntil(this.destroy$),
255+
);
256+
})
257+
).pipe(
258+
map((values: Record<string, unknown>[]) => values.reduce((groupValue, value) => {
259+
return {
260+
...groupValue,
261+
...value,
262+
};
263+
}, {})),
264+
tap(groupValue => this.getQueryParamGroup().setValue(groupValue, {
265+
emitEvent: !synthetic,
266+
emitModelToViewChange: false,
267+
})),
239268
);
240-
const newValue = partitionedQueryParam.reduce(newValues);
241-
242-
const directives = this.directives.get(queryParamName);
243-
if (directives) {
244-
directives.forEach(directive => directive.valueAccessor.writeValue(newValue));
245-
}
246-
247-
groupValue[ queryParamName ] = newValue;
248-
});
249-
250-
this.getQueryParamGroup().setValue(groupValue, {
251-
emitEvent: !synthetic,
252-
emitModelToViewChange: false,
253-
});
254-
});
269+
}),
270+
takeUntil(this.destroy$),
271+
).subscribe();
255272
}
256273

257274
/** Listens for newly added parameters and starts synchronization for them. */
@@ -350,7 +367,7 @@ export class QueryParamGroupService implements OnDestroy {
350367

351368
return {
352369
...(this.globalRouterOptions || {}),
353-
...groupOptions,
370+
...(groupOptions || {}),
354371
};
355372
}
356373

projects/ngqp/core/src/lib/model/query-param.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Observable, Subject } from 'rxjs';
2-
import { areEqualUsing, isFunction, isMissing, isPresent, undefinedToNull, wrapTryCatch } from '../util';
1+
import { forkJoin, isObservable, Observable, of, Subject } from 'rxjs';
2+
import { first } from 'rxjs/operators';
3+
import { areEqualUsing, isFunction, isMissing, isPresent, undefinedToNull, wrapIntoObservable, wrapTryCatch } from '../util';
34
import { Comparator, OnChangeFunction, ParamCombinator, ParamDeserializer, ParamSerializer, Partitioner, Reducer } from '../types';
45
import { QueryParamGroup } from './query-param-group';
56
import { MultiQueryParamOpts, PartitionedQueryParamOpts, QueryParamOpts, QueryParamOptsBase } from './query-param-opts';
@@ -117,12 +118,6 @@ export abstract class AbstractQueryParam<U, T> extends AbstractQueryParamBase<T>
117118
this.combineWith = combineWith;
118119
}
119120

120-
/** @internal */
121-
public abstract serializeValue(value: T | null): (string | null) | (string | null)[];
122-
123-
/** @internal */
124-
public abstract deserializeValue(value: (string | null) | (string | null)[]): T | null;
125-
126121
/**
127122
* Updates the value of this parameter.
128123
*
@@ -180,12 +175,12 @@ export class QueryParam<T> extends AbstractQueryParam<T | null, T | null> implem
180175
}
181176

182177
/** @internal */
183-
public deserializeValue(value: string | null): T | null {
178+
public deserializeValue(value: string | null): Observable<T | null> {
184179
if (this.emptyOn !== undefined && value === null) {
185-
return this.emptyOn;
180+
return of(this.emptyOn);
186181
}
187182

188-
return this.deserialize(value);
183+
return wrapIntoObservable(this.deserialize(value)).pipe(first());
189184
}
190185

191186
}
@@ -212,12 +207,14 @@ export class MultiQueryParam<T> extends AbstractQueryParam<T | null, (T | null)[
212207
}
213208

214209
/** @internal */
215-
public deserializeValue(value: (string | null)[] | null): (T | null)[] | null {
216-
if (this.emptyOn !== undefined && (value || []).length === 0) {
217-
return this.emptyOn;
210+
public deserializeValue(values: (string | null)[] | null): Observable<(T | null)[] | null> {
211+
if (this.emptyOn !== undefined && (values || []).length === 0) {
212+
return of(this.emptyOn);
218213
}
219214

220-
return (value || []).map(this.deserialize.bind(this));
215+
return forkJoin<T | null>(...(values || [])
216+
.map(value => wrapIntoObservable(this.deserialize(value)).pipe(first()))
217+
);
221218
}
222219

223220
}

projects/ngqp/core/src/lib/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Params } from '@angular/router';
2+
import { Observable } from 'rxjs';
23

34
/**
45
* A serializer defines how the represented form control's
@@ -10,7 +11,7 @@ export type ParamSerializer<T> = (model: T | null) => string | null;
1011
* A deserializer defines how a URL parameter is converted
1112
* into the represented form control's value.
1213
*/
13-
export type ParamDeserializer<T> = (value: string | null) => T | null;
14+
export type ParamDeserializer<T> = (value: string | null) => (T | null) | Observable<T | null>;
1415

1516
/**
1617
* A partitioner can split a value into an array of parts.

projects/ngqp/core/src/lib/util.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { convertToParamMap, Params } from '@angular/router';
2+
import { of } from 'rxjs';
23
import {
34
areEqualUsing,
45
compareParamMaps,
@@ -9,6 +10,7 @@ import {
910
isPresent,
1011
LOOSE_IDENTITY_COMPARATOR,
1112
undefinedToNull,
13+
wrapIntoObservable,
1214
wrapTryCatch
1315
} from './util';
1416
import { Comparator } from './types';
@@ -266,4 +268,27 @@ describe(compareParamMaps.name, () => {
266268
{ a: [ 'a2', 'a1' ] },
267269
)).toBe(true);
268270
});
271+
});
272+
273+
describe(wrapIntoObservable.name, async () => {
274+
it('wraps null', async () => {
275+
const obs$ = wrapIntoObservable(null);
276+
expect(obs$).toBeDefined();
277+
278+
obs$.subscribe(v => expect(v).toBe(null));
279+
});
280+
281+
it('wraps a primitive value', async () => {
282+
const obs$ = wrapIntoObservable(42);
283+
expect(obs$).toBeDefined();
284+
285+
obs$.subscribe(v => expect(v).toBe(42));
286+
});
287+
288+
it('does not wrap an observable', async () => {
289+
const obs$ = wrapIntoObservable(of(42));
290+
expect(obs$).toBeDefined();
291+
292+
obs$.subscribe(v => expect(v).toBe(42));
293+
});
269294
});

projects/ngqp/core/src/lib/util.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { convertToParamMap, ParamMap, Params } from '@angular/router';
2+
import { isObservable, Observable, of } from 'rxjs';
23
import { Comparator } from './types';
34

45
/** @internal */
@@ -96,4 +97,13 @@ export function compareStringArraysUnordered(first: string[], second: string[]):
9697
const sortedFirst = first.sort();
9798
const sortedSecond = second.sort();
9899
return sortedFirst.every((firstKey, index) => firstKey === sortedSecond[index]);
100+
}
101+
102+
/** @internal */
103+
export function wrapIntoObservable<T>(input: T | Observable<T>): Observable<T> {
104+
if (isObservable(input)) {
105+
return input;
106+
}
107+
108+
return of(input);
99109
}

projects/ngqp/core/src/test/serialize-deserialize.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Component } from '@angular/core';
22
import { Router } from '@angular/router';
33
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
44
import { RouterTestingModule } from '@angular/router/testing';
5+
import { of } from 'rxjs';
6+
import { delay } from 'rxjs/operators';
57
import { QueryParamBuilder, QueryParamGroup, QueryParamModule } from '../public_api';
68
import { setupNavigationWarnStub } from './util';
79

@@ -27,6 +29,28 @@ class BasicTestComponent {
2729

2830
}
2931

32+
@Component({
33+
template: `
34+
<div [queryParamGroup]="paramGroup">
35+
<input type="text" queryParamName="param" />
36+
</div>
37+
`,
38+
})
39+
class AsyncTestComponent {
40+
41+
public paramGroup: QueryParamGroup;
42+
43+
constructor(private qpb: QueryParamBuilder) {
44+
this.paramGroup = qpb.group({
45+
param: qpb.stringParam('q', {
46+
serialize: value => value ? value.toUpperCase() : null,
47+
deserialize: value => of(value ? value.toLowerCase() : null).pipe(delay(500)),
48+
}),
49+
});
50+
}
51+
52+
}
53+
3054
describe('(de-)serialize', () => {
3155
let fixture: ComponentFixture<BasicTestComponent>;
3256
let component: BasicTestComponent;
@@ -72,6 +96,50 @@ describe('(de-)serialize', () => {
7296
router.navigateByUrl('/?q=TEST');
7397
tick();
7498

99+
expect(input.value).toBe('test');
100+
}));
101+
});
102+
103+
describe('asynchronous (de-)serialize', () => {
104+
let fixture: ComponentFixture<AsyncTestComponent>;
105+
let component: AsyncTestComponent;
106+
let input: HTMLInputElement;
107+
let router: Router;
108+
109+
beforeEach(() => setupNavigationWarnStub());
110+
111+
beforeEach(async(() => {
112+
TestBed.configureTestingModule({
113+
imports: [
114+
RouterTestingModule.withRoutes([]),
115+
QueryParamModule,
116+
],
117+
declarations: [
118+
AsyncTestComponent,
119+
],
120+
});
121+
122+
router = TestBed.get(Router);
123+
TestBed.compileComponents();
124+
router.initialNavigation();
125+
}));
126+
127+
beforeEach(() => {
128+
fixture = TestBed.createComponent(AsyncTestComponent);
129+
component = fixture.componentInstance;
130+
fixture.detectChanges();
131+
132+
input = (fixture.nativeElement as HTMLElement).querySelector('input') as HTMLInputElement;
133+
fixture.detectChanges();
134+
});
135+
136+
it('applies the async deserializer when the URL changes', fakeAsync(() => {
137+
router.navigateByUrl('/?q=TEST');
138+
139+
tick();
140+
expect(input.value).toBe('');
141+
142+
tick(500);
75143
expect(input.value).toBe('test');
76144
}));
77145
});

0 commit comments

Comments
 (0)