Skip to content

Commit

Permalink
feat(core): add serializeAll / deserializeAll
Browse files Browse the repository at this point in the history
These override serialize/deserialize and allow more control over the
(de-)serialization for multi-values parameters.

fixes #145

Signed-off-by: Ingo Bürk <[email protected]>
  • Loading branch information
Airblader committed Apr 5, 2022
1 parent b22d432 commit 460ad28
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ <h2>Components with multiple values</h2>
behavior of the router to have query parameters with multiple values.
</p>
<div class="alert alert-info">
When using <code>multi: true</code>, APIs such as <span apiDocsLink>QueryParamGroup#get</span> will actually
return <span apiDocsLink>MultiQueryParam</span> instead of <span apiDocsLink>QueryParam</span>.
<p>
When using <code>multi: true</code>, APIs such as <span apiDocsLink>QueryParamGroup#get</span> will actually
return <span apiDocsLink>MultiQueryParam</span> instead of <span apiDocsLink>QueryParam</span>.
</p>
<p>
If you want to (de-)serialize values of multi-valued parameters at once, you can define
<span apiDocsLink>MultiQueryParamOpts#serializeAll</span> and <span apiDocsLink>MultiQueryParamOpts#deserializeAll</span>
instead of individual serializers.
</p>
</div>
<demo-multi-example></demo-multi-example>

Expand Down
24 changes: 23 additions & 1 deletion projects/ngqp/core/src/lib/model/query-param-opts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { Comparator, Reducer, Partitioner, ParamCombinator, ParamDeserializer, ParamSerializer } from '../types';
import {
Comparator,
Reducer,
Partitioner,
ParamCombinator,
ParamDeserializer,
ParamSerializer,
MultiParamSerializer, MultiParamDeserializer
} from '../types';

/**
* Configuration options for a {@link QueryParamGroup}.
Expand Down Expand Up @@ -90,6 +98,20 @@ export interface MultiQueryParamOpts<T> extends QueryParamOptsBase<T | null, (T
* multiple times, e.g. `https://www.app.io?param=A&param=B&param=C`.
*/
multi: true;

/**
* Like {@link QueryParamOptsBase#serialize}, but serializes all values at once.
*
* If set, this overrides any per-value serializer.
*/
serializeAll?: MultiParamSerializer<T>;

/**
* Like {@link QueryParamOptsBase#deserialize}, but deserializes all values at once.
*
* If set, this overrides any per-value deserializer.
*/
deserializeAll?: MultiParamDeserializer<T>;
}

/**
Expand Down
42 changes: 41 additions & 1 deletion projects/ngqp/core/src/lib/model/query-param.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
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 {
Comparator, MultiParamDeserializer,
MultiParamSerializer,
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 @@ -193,8 +202,31 @@ export class MultiQueryParam<T> extends AbstractQueryParam<T | null, (T | null)[
/** See {@link QueryParamOpts}. */
public readonly multi = true;

/** See {@link MultiQueryParamOpts}. */
public readonly serializeAll?: MultiParamSerializer<T>;

/** See {@link MultiQueryParamOpts}. */
public readonly deserializeAll?: MultiParamDeserializer<T>;

constructor(urlParam: string, opts: MultiQueryParamOpts<T>) {
super(urlParam, opts);
const { serializeAll, deserializeAll } = opts;

if (serializeAll !== undefined) {
if (!isFunction(serializeAll)) {
throw new Error(`serializeAll must be a function, but received ${serializeAll}`);
}

this.serializeAll = wrapTryCatch(serializeAll, `Error while serializing value for ${this.urlParam}`);
}

if (deserializeAll !== undefined) {
if (!isFunction(deserializeAll)) {
throw new Error(`deserializeAll must be a function, but received ${deserializeAll}`);
}

this.deserializeAll = wrapTryCatch(deserializeAll, `Error while deserializing value for ${this.urlParam}`);
}
}

/** @internal */
Expand All @@ -203,6 +235,10 @@ export class MultiQueryParam<T> extends AbstractQueryParam<T | null, (T | null)[
return null;
}

if (this.serializeAll !== undefined) {
return this.serializeAll(value);
}

return (value || []).map(this.serialize.bind(this));
}

Expand All @@ -212,6 +248,10 @@ export class MultiQueryParam<T> extends AbstractQueryParam<T | null, (T | null)[
return of(this.emptyOn);
}

if (this.deserializeAll !== undefined) {
return wrapIntoObservable(this.deserializeAll(values));
}

if (!values || values.length === 0) {
return of([]);
}
Expand Down
10 changes: 10 additions & 0 deletions projects/ngqp/core/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export type ParamSerializer<T> = (model: T | null) => string | null;
*/
export type ParamDeserializer<T> = (value: string | null) => (T | null) | Observable<T | null>;

/**
* Like {@link ParamSerializer}, but for an array of values for multi-values params.
*/
export type MultiParamSerializer<T> = (model: (T | null)[] | null) => (string | null)[] | null;

/**
* Like {@link ParamDeserializer}, but for an array of values for multi-values params.
*/
export type MultiParamDeserializer<T> = (value: (string | null)[] | null) => ((T | null)[] | null) | Observable<(T | null)[] | null>;

/**
* A partitioner can split a value into an array of parts.
*/
Expand Down
92 changes: 91 additions & 1 deletion projects/ngqp/core/src/test/serialize-deserialize.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { waitForAsync, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { delay } from 'rxjs/operators';
Expand Down Expand Up @@ -29,6 +29,37 @@ class BasicTestComponent {

}

@Component({
template: `
<div [queryParamGroup]="paramGroup">
<select multiple queryParamName="param">
<option id="a" value="v1">Option 1</option>
<option id="b" value="v2">Option 2</option>
<option id="c" value="v3">Option 3</option>
</select>
</div>
`,
})
class MultiTestComponent {

public paramGroup: QueryParamGroup;

constructor(private qpb: QueryParamBuilder) {
this.paramGroup = qpb.group({
param: qpb.stringParam('q', {
multi: true,
serializeAll: values => (values.length === 1 && values[0] === 'v2')
? null
: values.map(v => v.toUpperCase()),
deserializeAll: values => values.length === 0
? ['v2']
: values.map(v => v.toLowerCase()),
}),
});
}

}

@Component({
template: `
<div [queryParamGroup]="paramGroup">
Expand Down Expand Up @@ -100,6 +131,65 @@ describe('(de-)serialize', () => {
}));
});

describe('(de-)serialize for multi', () => {
let fixture: ComponentFixture<MultiTestComponent>;
let component: MultiTestComponent;
let select: HTMLSelectElement;
let router: Router;

beforeEach(() => setupNavigationWarnStub());

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([]),
QueryParamModule,
],
declarations: [
MultiTestComponent,
],
});

router = TestBed.inject(Router);
TestBed.compileComponents();
router.initialNavigation();
}));

beforeEach(() => {
fixture = TestBed.createComponent(MultiTestComponent);
component = fixture.componentInstance;
fixture.detectChanges();

select = (fixture.nativeElement as HTMLElement).querySelector('select') as HTMLSelectElement;
fixture.detectChanges();
});

it('selects the default value on initial routing', fakeAsync(() => {
tick();

expect(select.namedItem('a').selected).toBe(false);
expect(select.namedItem('b').selected).toBe(true);
expect(select.namedItem('c').selected).toBe(false);
}));

it('applies a custom serializer when changing the form control', fakeAsync(() => {
select.namedItem('a').selected = true;
select.dispatchEvent(new Event('change'));
tick();

expect(router.url).toBe('/?q=V1&q=V2');
}));

it('applies the deserializer when the URL changes', fakeAsync(() => {
router.navigateByUrl('/?q=V3');
tick();

expect(select.namedItem('a').selected).toBe(false);
expect(select.namedItem('b').selected).toBe(false);
expect(select.namedItem('c').selected).toBe(true);
}));
});

describe('asynchronous (de-)serialize', () => {
let fixture: ComponentFixture<AsyncTestComponent>;
let component: AsyncTestComponent;
Expand Down

0 comments on commit 460ad28

Please sign in to comment.