Skip to content

Commit

Permalink
Merge pull request #131 from TNG/feature-93
Browse files Browse the repository at this point in the history
feat(core): support asynchronous deserializers
  • Loading branch information
Airblader authored Apr 26, 2019
2 parents 9fb8ed5 + 428e266 commit 2fc8051
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ <h2>Serializing and deserializing</h2>
provide your own (de-)serializers, you can pass them to <span apiDocsLink>QueryParamBuilder#param</span>.
</p>
<demo-serializer-example></demo-serializer-example>
<div class="alert alert-info mt-3">
<p>
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.
</p>
<p>
Note that for asynchronous deserializers, ngqp will use the <em>first</em> emitted value rather than the
last one. Furthermore, the <code>valueChanges</code> observables will only emit after the deserializer has
emitted.
</p>
</div>

<docs-fragment fragment="multi">
<h2>Components with multiple values</h2>
Expand Down
2 changes: 1 addition & 1 deletion projects/ngqp-demo/src/app/home/home.component.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
73 changes: 45 additions & 28 deletions projects/ngqp/core/src/lib/directives/query-param-group.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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<string, unknown> = {};

Object.keys(this.getQueryParamGroup().queryParams).forEach(queryParamName => {
const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName);
const newValues = partitionedQueryParam.queryParams.map(queryParam => isMultiQueryParam<unknown>(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<Record<string, unknown>>(...queryParamNames
.map(queryParamName => {
const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName);

return forkJoin<unknown>(...partitionedQueryParam.queryParams
.map(queryParam => isMultiQueryParam<unknown>(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<string, unknown>[]) => 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. */
Expand Down Expand Up @@ -350,7 +367,7 @@ export class QueryParamGroupService implements OnDestroy {

return {
...(this.globalRouterOptions || {}),
...groupOptions,
...(groupOptions || {}),
};
}

Expand Down
27 changes: 12 additions & 15 deletions projects/ngqp/core/src/lib/model/query-param.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -117,12 +118,6 @@ export abstract class AbstractQueryParam<U, T> extends AbstractQueryParamBase<T>
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.
*
Expand Down Expand Up @@ -180,12 +175,12 @@ export class QueryParam<T> extends AbstractQueryParam<T | null, T | null> implem
}

/** @internal */
public deserializeValue(value: string | null): T | null {
public deserializeValue(value: string | null): Observable<T | null> {
if (this.emptyOn !== undefined && value === null) {
return this.emptyOn;
return of(this.emptyOn);
}

return this.deserialize(value);
return wrapIntoObservable(this.deserialize(value)).pipe(first());
}

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

/** @internal */
public deserializeValue(value: (string | null)[] | null): (T | null)[] | null {
if (this.emptyOn !== undefined && (value || []).length === 0) {
return this.emptyOn;
public deserializeValue(values: (string | null)[] | null): Observable<(T | null)[] | null> {
if (this.emptyOn !== undefined && (values || []).length === 0) {
return of(this.emptyOn);
}

return (value || []).map(this.deserialize.bind(this));
return forkJoin<T | null>(...(values || [])
.map(value => wrapIntoObservable(this.deserialize(value)).pipe(first()))
);
}

}
Expand Down
3 changes: 2 additions & 1 deletion projects/ngqp/core/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Params } from '@angular/router';
import { Observable } from 'rxjs';

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

/**
* A partitioner can split a value into an array of parts.
Expand Down
25 changes: 25 additions & 0 deletions projects/ngqp/core/src/lib/util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { convertToParamMap, Params } from '@angular/router';
import { of } from 'rxjs';
import {
areEqualUsing,
compareParamMaps,
Expand All @@ -9,6 +10,7 @@ import {
isPresent,
LOOSE_IDENTITY_COMPARATOR,
undefinedToNull,
wrapIntoObservable,
wrapTryCatch
} from './util';
import { Comparator } from './types';
Expand Down Expand Up @@ -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));
});
});
10 changes: 10 additions & 0 deletions projects/ngqp/core/src/lib/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { convertToParamMap, ParamMap, Params } from '@angular/router';
import { isObservable, Observable, of } from 'rxjs';
import { Comparator } from './types';

/** @internal */
Expand Down Expand Up @@ -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<T>(input: T | Observable<T>): Observable<T> {
if (isObservable(input)) {
return input;
}

return of(input);
}
68 changes: 68 additions & 0 deletions projects/ngqp/core/src/test/serialize-deserialize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,6 +29,28 @@ class BasicTestComponent {

}

@Component({
template: `
<div [queryParamGroup]="paramGroup">
<input type="text" queryParamName="param" />
</div>
`,
})
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<BasicTestComponent>;
let component: BasicTestComponent;
Expand Down Expand Up @@ -72,6 +96,50 @@ describe('(de-)serialize', () => {
router.navigateByUrl('/?q=TEST');
tick();

expect(input.value).toBe('test');
}));
});

describe('asynchronous (de-)serialize', () => {
let fixture: ComponentFixture<AsyncTestComponent>;
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');
}));
});

0 comments on commit 2fc8051

Please sign in to comment.