@ngqp/core
Version:
1,315 lines (1,301 loc) • 66.1 kB
JavaScript
import { ɵɵdefineInjectable, Injectable, InjectionToken, isDevMode, Inject, Optional, forwardRef, Directive, PLATFORM_ID, Renderer2, ElementRef, HostListener, Self, Input, EventEmitter, Output, Host, NgModule } from '@angular/core';
import { convertToParamMap, Router, ActivatedRoute } from '@angular/router';
import { isObservable, of, Subject, forkJoin, zip, from, EMPTY } from 'rxjs';
import { first, debounceTime, tap, filter, map, takeUntil, startWith, switchMap, distinctUntilChanged, concatMap, catchError } from 'rxjs/operators';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { isPlatformBrowser } from '@angular/common';
/** @internal */
// tslint:disable-next-line:triple-equals
const LOOSE_IDENTITY_COMPARATOR = (a, b) => a == b;
/** @internal */
const NOP = () => { };
/** @internal */
function isMissing(obj) {
return obj === undefined || obj === null;
}
/** @internal */
function undefinedToNull(obj) {
if (obj === undefined) {
return null;
}
return obj;
}
/** @internal */
function isPresent(obj) {
return !isMissing(obj);
}
/** @internal */
function isFunction(obj) {
return isPresent(obj) && typeof obj === 'function';
}
/** @internal */
function wrapTryCatch(fn, msg) {
return function () {
try {
return fn.apply(this, arguments);
}
catch (err) {
console.error(msg, err);
return null;
}
};
}
/** @internal */
function areEqualUsing(first, second, comparator) {
const comparison = comparator(first, second);
if (typeof comparison === 'boolean') {
return comparison;
}
return comparison === 0;
}
/** @internal */
function filterParamMap(paramMap, keys) {
const params = {};
keys
.filter(key => paramMap.keys.includes(key))
.forEach(key => params[key] = paramMap.getAll(key));
return convertToParamMap(params);
}
/** @internal */
function compareParamMaps(first, second) {
if ((first && !second) || (second && !first)) {
return false;
}
if (!compareStringArraysUnordered(first.keys, second.keys)) {
return false;
}
return first.keys.every(key => compareStringArraysUnordered(first.getAll(key), second.getAll(key)));
}
/** @internal */
function compareStringArraysUnordered(first, second) {
if (!first && !second) {
return true;
}
if ((first && !second) || (second && !first)) {
return false;
}
if (first.length !== second.length) {
return false;
}
const sortedFirst = first.sort();
const sortedSecond = second.sort();
return sortedFirst.every((firstKey, index) => firstKey === sortedSecond[index]);
}
/** @internal */
function wrapIntoObservable(input) {
if (isObservable(input)) {
return input;
}
return of(input);
}
/**
* Creates a serializer for parameters of type `string`.
*
* @param defaultValue Optional default value to return if the value to serialize is `undefined` or `null`.
*/
function createStringSerializer(defaultValue = null) {
return model => isMissing(model) ? defaultValue : model;
}
/**
* Creates a deserializer for parameters of type `string`.
*
* @param defaultValue Optional default value to return if the value to deserialize is `undefined` or `null`.
*/
function createStringDeserializer(defaultValue = null) {
return value => isMissing(value) ? defaultValue : value;
}
/**
* Creates a serializer for parameters of type `number`.
*
* @param defaultValue Optional default value to return if the value to serialize is `undefined` or `null`.
*/
function createNumberSerializer(defaultValue = null) {
return model => isMissing(model) ? defaultValue : `${model}`;
}
/**
* Creates a deserializer for parameters of type `number`.
*
* @param defaultValue Optional default value to return if the value to deserialize is `undefined` or `null`.
*/
function createNumberDeserializer(defaultValue = null) {
return value => isMissing(value) ? defaultValue : parseFloat(value);
}
/**
* Creates a serializer for parameters of type `boolean`.
*
* @param defaultValue Optional default value to return if the value to serialize is `undefined` or `null`.
*/
function createBooleanSerializer(defaultValue = null) {
return model => isMissing(model) ? defaultValue : `${model}`;
}
/**
* Creates a deserializer for parameters of type `boolean`.
*
* @param defaultValue Optional default value to return if the value to deserialize is `undefined` or `null`.
*/
function createBooleanDeserializer(defaultValue = null) {
return value => isMissing(value) ? defaultValue : (value === 'true' || value === '1');
}
/** @internal */
const DEFAULT_STRING_SERIALIZER = createStringSerializer();
/** @internal */
const DEFAULT_STRING_DESERIALIZER = createStringDeserializer();
/** @internal */
const DEFAULT_NUMBER_SERIALIZER = createNumberSerializer();
/** @internal */
const DEFAULT_NUMBER_DESERIALIZER = createNumberDeserializer();
/** @internal */
const DEFAULT_BOOLEAN_SERIALIZER = createBooleanSerializer();
/** @internal */
const DEFAULT_BOOLEAN_DESERIALIZER = createBooleanDeserializer();
/** @internal */
class AbstractQueryParamBase {
constructor() {
this.parent = null;
this._valueChanges = new Subject();
this.changeFunctions = [];
/**
* Emits the current value of this parameter whenever it changes.
*
* NOTE: This observable does not complete on its own, so ensure to unsubscribe from it.
*/
this.valueChanges = this._valueChanges.asObservable();
}
_registerOnChange(fn) {
this.changeFunctions.push(fn);
}
_clearChangeFunctions() {
this.changeFunctions = [];
}
_setParent(parent) {
if (this.parent && parent) {
throw new Error(`Parameter already belongs to a QueryParamGroup.`);
}
this.parent = parent;
}
}
/**
* Abstract base for {@link QueryParam} and {@link MultiQueryParam}.
*
* This base class holds most of the parameter's options, but is unaware of
* how to actually (de-)serialize any values.
*/
class AbstractQueryParam extends AbstractQueryParamBase {
constructor(urlParam, opts = {}) {
super();
/**
* The current value of this parameter.
*/
this.value = null;
const { serialize, deserialize, debounceTime, compareWith, emptyOn, combineWith } = opts;
if (isMissing(urlParam)) {
throw new Error(`Please provide a URL parameter name for each query parameter.`);
}
if (!isFunction(serialize)) {
throw new Error(`serialize must be a function, but received ${serialize}`);
}
if (!isFunction(deserialize)) {
throw new Error(`deserialize must be a function, but received ${deserialize}`);
}
if (emptyOn !== undefined && !isFunction(compareWith)) {
throw new Error(`compareWith must be a function, but received ${compareWith}`);
}
if (isPresent(combineWith) && !isFunction(combineWith)) {
throw new Error(`combineWith must be a function, but received ${combineWith}`);
}
this.urlParam = urlParam;
this.serialize = wrapTryCatch(serialize, `Error while serializing value for ${this.urlParam}`);
this.deserialize = wrapTryCatch(deserialize, `Error while deserializing value for ${this.urlParam}`);
this.debounceTime = undefinedToNull(debounceTime);
this.emptyOn = emptyOn;
this.compareWith = compareWith;
this.combineWith = combineWith;
}
/**
* Updates the value of this parameter.
*
* If wired up with a {@link QueryParamGroup}, this will also synchronize
* the value to the URL.
*/
setValue(value, opts = {}) {
this.value = value;
if (opts.emitModelToViewChange !== false) {
this.changeFunctions.forEach(changeFn => changeFn(value));
}
if (opts.emitEvent !== false) {
this._valueChanges.next(this.value);
}
if (isPresent(this.parent) && !opts.onlySelf) {
this.parent._updateValue({
emitEvent: opts.emitEvent,
emitModelToViewChange: false,
});
}
}
}
/**
* Describes a single parameter.
*
* This is the description of a single parameter and essentially serves
* as the glue between its representation in the URL and its connection
* to a form control.
*/
class QueryParam extends AbstractQueryParam {
constructor(urlParam, opts) {
super(urlParam, opts);
/** See {@link QueryParamOpts}. */
this.multi = false;
}
/** @internal */
serializeValue(value) {
if (this.emptyOn !== undefined && areEqualUsing(value, this.emptyOn, this.compareWith)) {
return null;
}
return this.serialize(value);
}
/** @internal */
deserializeValue(value) {
if (this.emptyOn !== undefined && value === null) {
return of(this.emptyOn);
}
return wrapIntoObservable(this.deserialize(value)).pipe(first());
}
}
/**
* Like {@link QueryParam}, but for array-typed parameters
*/
class MultiQueryParam extends AbstractQueryParam {
constructor(urlParam, opts) {
super(urlParam, opts);
/** See {@link QueryParamOpts}. */
this.multi = true;
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 */
serializeValue(value) {
if (this.emptyOn !== undefined && areEqualUsing(value, this.emptyOn, this.compareWith)) {
return null;
}
if (this.serializeAll !== undefined) {
return this.serializeAll(value);
}
return (value || []).map(this.serialize.bind(this));
}
/** @internal */
deserializeValue(values) {
if (this.emptyOn !== undefined && (values || []).length === 0) {
return of(this.emptyOn);
}
if (this.deserializeAll !== undefined) {
return wrapIntoObservable(this.deserializeAll(values));
}
if (!values || values.length === 0) {
return of([]);
}
return forkJoin(...values
.map(value => wrapIntoObservable(this.deserialize(value)).pipe(first())));
}
}
/**
* Describes a partitioned query parameter.
*
* This encapsulates a list of query parameters such that a single form control
* can be bound against multiple URL parameters. To achieve this, functions must
* be defined which can convert the models between the parameters.
*/
class PartitionedQueryParam extends AbstractQueryParamBase {
constructor(queryParams, opts) {
super();
if (queryParams.length === 0) {
throw new Error(`Partitioned parameters must contain at least one parameter.`);
}
if (!isFunction(opts.partition)) {
throw new Error(`partition must be a function, but received ${opts.partition}`);
}
if (!isFunction(opts.reduce)) {
throw new Error(`reduce must be a function, but received ${opts.reduce}`);
}
this.queryParams = queryParams;
this.partition = opts.partition;
this.reduce = opts.reduce;
}
get value() {
return this.reduce(this.queryParams.map(queryParam => queryParam.value));
}
setValue(value, opts = {}) {
const partitioned = this.partition(value);
this.queryParams.forEach((queryParam, index) => queryParam.setValue(partitioned[index], {
emitEvent: opts.emitEvent,
onlySelf: true,
emitModelToViewChange: false,
}));
if (opts.emitModelToViewChange !== false) {
this.changeFunctions.forEach(changeFn => changeFn(this.value));
}
if (opts.emitEvent !== false) {
this._valueChanges.next(this.value);
}
}
}
/**
* Groups multiple {@link QueryParam} instances to a single unit.
*
* This "bundles" multiple parameters together such that changes can be emitted as a
* complete unit. Collecting parameters into a group is required for the synchronization
* to and from the URL.
*/
class QueryParamGroup {
constructor(queryParams, extras = {}) {
/** @internal */
this._valueChanges = new Subject();
/**
* Emits the values of all parameters in this group whenever at least one changes.
*
* This observable emits an object keyed by the {@QueryParam} names where each key
* carries the current value of the represented parameter. It emits whenever at least
* one parameter's value is changed.
*
* NOTE: This observable does not complete on its own, so ensure to unsubscribe from it.
*/
this.valueChanges = this._valueChanges.asObservable();
/** @internal */
this._queryParamAdded$ = new Subject();
/** @internal */
this.queryParamAdded$ = this._queryParamAdded$.asObservable();
this.changeFunctions = [];
this.queryParams = queryParams;
this.routerOptions = extras;
this.options = extras;
Object.values(this.queryParams).forEach(queryParam => queryParam._setParent(this));
}
/** @internal */
_registerOnChange(fn) {
this.changeFunctions.push(fn);
}
/** @internal */
_clearChangeFunctions() {
this.changeFunctions = [];
}
/**
* Retrieves a specific parameter from this group by name.
*
* This returns an instance of either {@link QueryParam}, {@link MultiQueryParam}
* or {@link PartitionedQueryParam} depending on the configuration, or `null`
* if no parameter with that name is found in this group.
*
* @param queryParamName The name of the parameter instance to retrieve.
*/
get(queryParamName) {
const param = this.queryParams[queryParamName];
if (!param) {
return null;
}
return param;
}
/**
* Adds a new {@link QueryParam} to this group.
*
* This adds the parameter under the given name to this group. The current
* URL will be evaluated to synchronize its value initially. Afterwards
* it is treated just like any other parameter in this group.
*
* @param queryParamName Name of the parameter to reference it with.
* @param queryParam The new parameter to add.
*/
add(queryParamName, queryParam) {
if (this.get(queryParamName)) {
throw new Error(`A parameter with name ${queryParamName} already exists.`);
}
this.queryParams[queryParamName] = queryParam;
queryParam._setParent(this);
this._queryParamAdded$.next(queryParamName);
}
/**
* Removes a {@link QueryParam} from this group.
*
* This removes the parameter defined by the provided name from this group.
* No further synchronization with this parameter will occur and it will not
* be reported in the value of this group anymore.
*
* @param queryParamName The name of the parameter to remove.
*/
remove(queryParamName) {
const queryParam = this.get(queryParamName);
if (!queryParam) {
throw new Error(`No parameter with name ${queryParamName} found.`);
}
delete this.queryParams[queryParamName];
queryParam._setParent(null);
queryParam._clearChangeFunctions();
}
/**
* The current value of this group.
*
* See {@link QueryParamGroup#valueChanges} for a description of the format of
* the value.
*/
get value() {
const value = {};
Object.keys(this.queryParams).forEach(queryParamName => value[queryParamName] = this.queryParams[queryParamName].value);
return value;
}
/**
* Updates the value of this group by merging it in.
*
* This sets the value of each provided parameter to the respective provided
* value. If a parameter is not listed, its value remains unchanged.
*
* @param value See {@link QueryParamGroup#valueChanges} for a description of the format.
* @param opts Additional options
*/
patchValue(value, opts = {}) {
Object.keys(value).forEach(queryParamName => {
const queryParam = this.queryParams[queryParamName];
if (isMissing(queryParam)) {
return;
}
queryParam.setValue(value[queryParamName], {
emitEvent: opts.emitEvent,
onlySelf: true,
emitModelToViewChange: false,
});
});
this._updateValue(opts);
}
/**
* Updates the value of this group by overwriting it.
*
* This sets the value of each provided parameter to the respective provided
* value. If a parameter is not listed, its value is set to `undefined`.
*
* @param value See {@link QueryParamGroup#valueChanges} for a description of the format.
* @param opts Additional options
*/
setValue(value, opts = {}) {
Object.keys(this.queryParams).forEach(queryParamName => {
this.queryParams[queryParamName].setValue(undefinedToNull(value === null || value === void 0 ? void 0 : value[queryParamName]), {
emitEvent: opts.emitEvent,
onlySelf: true,
emitModelToViewChange: false,
});
});
this._updateValue(opts);
}
/** @internal */
_updateValue(opts = {}) {
if (opts.emitModelToViewChange !== false) {
this.changeFunctions.forEach(changeFn => changeFn(this.value));
}
if (opts.emitEvent !== false) {
this._valueChanges.next(this.value);
}
}
}
function isMultiOpts(opts) {
return opts.multi === true;
}
/**
* Service to create parameters and groups.
*
* This service provides a simple API to create {@link QueryParamGroup} and {@link QueryParam}
* instances and is the recommended way to set them up.
*/
class QueryParamBuilder {
/**
* Creates a new {@link QueryParamGroup}.
*
* This is the primary method to create a new group of parameters. Pass a list of
* {@link QueryParam} instances by using the `xxxParam` methods.
*
* @param queryParams List of {@link QueryParam}s keyed by a unique name.
* @param extras Additional parameters for this group, overriding global configuration.
* @returns The new {@link QueryParamGroup}.
*/
group(queryParams, extras = {}) {
// TODO Maybe we should first validate that no two queryParams defined the same "param".
return new QueryParamGroup(queryParams, extras);
}
/**
* Partition a query parameter into multiple others.
*
* Partitioning is useful if you need to bind a single form control to multiple query parameters.
* For example, consider a {@code <select>} which represents both a field to sort by and the
* direction to sort in. If you want to encode these two information on separate URL parameters,
* you can define a single query parameter that is partitioned into two others.
*
* @param queryParams The query parameters making up this partition.
* @param opts See {@link PartitionedQueryParamOpts}.
*/
partition(queryParams, opts) {
return new PartitionedQueryParam(queryParams, opts);
}
/**
* Create a new parameter of type `string`.
*
* See {@link QueryParamOpts}.
*/
stringParam(urlParam, opts = {}) {
opts = Object.assign({ serialize: DEFAULT_STRING_SERIALIZER, deserialize: DEFAULT_STRING_DESERIALIZER, compareWith: LOOSE_IDENTITY_COMPARATOR }, opts);
if (isMultiOpts(opts)) {
return new MultiQueryParam(urlParam, opts);
}
else {
return new QueryParam(urlParam, opts);
}
}
/**
* Create a new parameter of type `number`.
*
* See {@link QueryParamOpts}.
*/
numberParam(urlParam, opts = {}) {
opts = Object.assign({ serialize: DEFAULT_NUMBER_SERIALIZER, deserialize: DEFAULT_NUMBER_DESERIALIZER, compareWith: LOOSE_IDENTITY_COMPARATOR }, opts);
if (isMultiOpts(opts)) {
return new MultiQueryParam(urlParam, opts);
}
else {
return new QueryParam(urlParam, opts);
}
}
/**
* Create a new parameter of type `boolean`.
*
* See {@link QueryParamOpts}.
*/
booleanParam(urlParam, opts = {}) {
opts = Object.assign({ serialize: DEFAULT_BOOLEAN_SERIALIZER, deserialize: DEFAULT_BOOLEAN_DESERIALIZER, compareWith: LOOSE_IDENTITY_COMPARATOR }, opts);
if (isMultiOpts(opts)) {
return new MultiQueryParam(urlParam, opts);
}
else {
return new QueryParam(urlParam, opts);
}
}
/**
* Create a new parameter for a complex type.
*
* See {@link QueryParamOpts}.
*/
param(urlParam, opts = {}) {
opts = Object.assign({ compareWith: LOOSE_IDENTITY_COMPARATOR }, opts);
if (isMultiOpts(opts)) {
return new MultiQueryParam(urlParam, opts);
}
else {
return new QueryParam(urlParam, opts);
}
}
}
QueryParamBuilder.ɵprov = ɵɵdefineInjectable({ factory: function QueryParamBuilder_Factory() { return new QueryParamBuilder(); }, token: QueryParamBuilder, providedIn: "root" });
QueryParamBuilder.decorators = [
{ type: Injectable, args: [{
providedIn: 'root'
},] }
];
/**
* See {@link RouterOptions}.
*/
const DefaultRouterOptions = {
replaceUrl: true,
preserveFragment: true,
};
/** @internal */
const NGQP_ROUTER_ADAPTER = new InjectionToken('NGQP_ROUTER_ADAPTER');
/** Injection token to provide {@link RouterOptions}. */
const NGQP_ROUTER_OPTIONS = new InjectionToken('NGQP_ROUTER_OPTIONS');
/** @internal */
function isMultiQueryParam(queryParam) {
return queryParam.multi;
}
/** @internal */
class NavigationData {
constructor(params, synthetic = false) {
this.params = params;
this.synthetic = synthetic;
}
}
/**
* Service implementing the synchronization logic
*
* This service is the key to the synchronization process by binding a {@link QueryParamGroup}
* to the router.
*
* @internal
*/
class QueryParamGroupService {
constructor(routerAdapter, globalRouterOptions) {
this.routerAdapter = routerAdapter;
this.globalRouterOptions = globalRouterOptions;
/** The {@link QueryParamGroup} to bind. */
this.queryParamGroup = null;
/** List of {@link QueryParamAccessor} registered to this service. */
this.directives = new Map();
/**
* Queue of navigation parameters
*
* A queue is used for navigations as we need to make sure all parameter changes
* are executed in sequence as otherwise navigations might overwrite each other.
*/
this.queue$ = new Subject();
/** @ignore */
this.synchronizeRouter$ = new Subject();
/** @ignore */
this.destroy$ = new Subject();
this.setupNavigationQueue();
}
/** @ignore */
ngOnDestroy() {
var _a, _b, _c;
this.destroy$.next();
this.destroy$.complete();
this.synchronizeRouter$.complete();
(_a = this.queryParamGroup) === null || _a === void 0 ? void 0 : _a._clearChangeFunctions();
if ((_c = (_b = this.queryParamGroup) === null || _b === void 0 ? void 0 : _b.options) === null || _c === void 0 ? void 0 : _c.clearOnDestroy) {
const nullParams = Object.values(this.queryParamGroup.queryParams)
.map(queryParam => this.wrapIntoPartition(queryParam))
.map(partitionedQueryParam => partitionedQueryParam.queryParams.map(queryParam => queryParam.urlParam))
.reduce((a, b) => [...a, ...b], [])
.map(urlParam => ({ [urlParam]: null }))
.reduce((a, b) => (Object.assign(Object.assign({}, a), b)), {});
this.routerAdapter.navigate(nullParams, {
replaceUrl: true,
}).then();
}
}
/**
* Uses the given {@link QueryParamGroup} for synchronization.
*/
setQueryParamGroup(queryParamGroup) {
// FIXME: If this is called when we already have a group, we probably need to do
// some cleanup first.
if (this.queryParamGroup) {
throw new Error(`A QueryParamGroup has already been setup. Changing the group is currently not supported.`);
}
this.queryParamGroup = queryParamGroup;
this.startSynchronization();
}
/**
* Registers a {@link QueryParamAccessor}.
*/
registerQueryParamDirective(directive) {
if (!directive.valueAccessor) {
throw new Error(`No value accessor found for the form control. Please make sure to implement ControlValueAccessor on this component.`);
}
// Capture the name here, particularly for the queue below to avoid re-evaluating
// it as it might change over time.
const queryParamName = directive.name;
const partitionedQueryParam = this.getQueryParamAsPartition(queryParamName);
// Chances are that we read the initial route before a directive has been registered here.
// The value in the model will be correct, but we need to sync it to the view once initially.
directive.valueAccessor.writeValue(partitionedQueryParam.value);
// Proxy updates from the view to debounce them (if needed).
const queues = partitionedQueryParam.queryParams.map(() => new Subject());
zip(...queues.map((queue$, index) => {
const queryParam = partitionedQueryParam.queryParams[index];
return queue$.pipe(isPresent(queryParam.debounceTime) ? debounceTime(queryParam.debounceTime) : tap());
})).pipe(
// Do not synchronize while the param is detached from the group
filter(() => !!this.getQueryParamGroup().get(queryParamName)), map((newValue) => this.getParamsForValue(partitionedQueryParam, partitionedQueryParam.reduce(newValue))), takeUntil(this.destroy$)).subscribe(params => this.enqueueNavigation(new NavigationData(params)));
directive.valueAccessor.registerOnChange((newValue) => {
const partitioned = partitionedQueryParam.partition(newValue);
queues.forEach((queue$, index) => queue$.next(partitioned[index]));
});
this.directives.set(queryParamName, [...(this.directives.get(queryParamName) || []), directive]);
}
/**
* Deregisters a {@link QueryParamAccessor} by referencing its name.
*/
deregisterQueryParamDirective(queryParamName) {
if (!queryParamName) {
return;
}
const directives = this.directives.get(queryParamName);
if (!directives) {
return;
}
directives.forEach(directive => {
directive.valueAccessor.registerOnChange(NOP);
directive.valueAccessor.registerOnTouched(NOP);
});
this.directives.delete(queryParamName);
const queryParam = this.getQueryParamGroup().get(queryParamName);
if (queryParam) {
queryParam._clearChangeFunctions();
}
}
startSynchronization() {
this.setupGroupChangeListener();
this.setupParamChangeListeners();
this.setupRouterListener();
this.watchNewParams();
}
/** Listens for programmatic changes on group level and synchronizes to the router. */
setupGroupChangeListener() {
this.getQueryParamGroup()._registerOnChange((newValue) => {
if (newValue === null) {
throw new Error(`Received null value from QueryParamGroup.`);
}
let params = {};
Object.keys(newValue).forEach(queryParamName => {
const queryParam = this.getQueryParamGroup().get(queryParamName);
if (isMissing(queryParam)) {
return;
}
params = Object.assign(Object.assign({}, params), this.getParamsForValue(queryParam, newValue[queryParamName]));
});
this.enqueueNavigation(new NavigationData(params, true));
});
}
/** Listens for programmatic changes on parameter level and synchronizes to the router. */
setupParamChangeListeners() {
Object.keys(this.getQueryParamGroup().queryParams)
.forEach(queryParamName => this.setupParamChangeListener(queryParamName));
}
setupParamChangeListener(queryParamName) {
const queryParam = this.getQueryParamGroup().get(queryParamName);
if (!queryParam) {
throw new Error(`No param in group found for name ${queryParamName}`);
}
queryParam._registerOnChange((newValue) => this.enqueueNavigation(new NavigationData(this.getParamsForValue(queryParam, newValue), true)));
}
/** Listens for changes in the router and synchronizes to the model. */
setupRouterListener() {
this.synchronizeRouter$.pipe(startWith(undefined), switchMap(() => this.routerAdapter.queryParamMap.pipe(
// We want to ignore changes to query parameters which aren't related to this
// particular group; however, we do need to react if one of our parameters has
// vanished when it was set before.
distinctUntilChanged((previousMap, currentMap) => {
const keys = Object.values(this.getQueryParamGroup().queryParams)
.map(queryParam => this.wrapIntoPartition(queryParam))
.map(partitionedQueryParam => partitionedQueryParam.queryParams.map(queryParam => queryParam.urlParam))
.reduce((a, b) => [...a, ...b], []);
// It is important that we filter the maps only here so that both are filtered
// with the same set of keys; otherwise, e.g. removing a parameter from the group
// would interfere.
return compareParamMaps(filterParamMap(previousMap, keys), filterParamMap(currentMap, keys));
}))), 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) => values.reduce((groupValue, value) => {
return Object.assign(Object.assign({}, groupValue), value);
}, {})), tap(groupValue => this.getQueryParamGroup().setValue(groupValue, {
emitEvent: !synthetic,
emitModelToViewChange: false,
})));
}), takeUntil(this.destroy$)).subscribe();
}
/** Listens for newly added parameters and starts synchronization for them. */
watchNewParams() {
this.getQueryParamGroup().queryParamAdded$.pipe(takeUntil(this.destroy$)).subscribe(queryParamName => {
this.setupParamChangeListener(queryParamName);
this.synchronizeRouter$.next();
});
}
/** Returns true if the current navigation is synthetic. */
isSyntheticNavigation() {
const navigation = this.routerAdapter.getCurrentNavigation();
if (!navigation || navigation.trigger !== 'imperative') {
// When using the back / forward buttons, the state is passed along with it, even though
// for us it's now a navigation initiated by the user. Therefore, a navigation can only
// be synthetic if it has been triggered imperatively.
// See https://github.com/angular/angular/issues/28108.
return false;
}
return navigation.extras && navigation.extras.state && navigation.extras.state['synthetic'];
}
/** Subscribes to the parameter queue and executes navigations in sequence. */
setupNavigationQueue() {
this.queue$.pipe(takeUntil(this.destroy$), concatMap(data => this.navigateSafely(data))).subscribe();
}
navigateSafely(data) {
return from(this.routerAdapter.navigate(data.params, Object.assign(Object.assign({}, this.routerOptions), { state: { synthetic: data.synthetic } }))).pipe(catchError((err) => {
if (isDevMode()) {
console.error(`There was an error while navigating`, err);
}
return EMPTY;
}));
}
/** Sends a change of parameters to the queue. */
enqueueNavigation(data) {
this.queue$.next(data);
}
/**
* Returns the full set of parameters given a value for a parameter model.
*
* This consists mainly of properly serializing the model value and ensuring to take
* side effect changes into account that may have been configured.
*/
getParamsForValue(queryParam, value) {
const partitionedQueryParam = this.wrapIntoPartition(queryParam);
const partitioned = partitionedQueryParam.partition(value);
const combinedParams = partitionedQueryParam.queryParams
.map((current, index) => isMissing(current.combineWith) ? null : current.combineWith(partitioned[index]))
.reduce((a, b) => {
return Object.assign(Object.assign({}, (a || {})), (b || {}));
}, {});
const newValues = partitionedQueryParam.queryParams
.map((current, index) => {
return {
[current.urlParam]: current.serializeValue(partitioned[index]),
};
})
.reduce((a, b) => {
return Object.assign(Object.assign({}, a), b);
}, {});
// Note that we list the side-effect parameters first so that our actual parameter can't be
// overridden by it.
return Object.assign(Object.assign({}, combinedParams), newValues);
}
/**
* Returns the current set of options to pass to the router.
*
* This merges the global configuration with the group specific configuration.
*/
get routerOptions() {
const groupOptions = this.getQueryParamGroup().routerOptions;
return Object.assign(Object.assign({}, (this.globalRouterOptions || {})), (groupOptions || {}));
}
/**
* Returns the query parameter with the given name as a partition.
*
* If the query parameter is partitioned, it is returned unchanged. Otherwise
* it is wrapped into a noop partition. This makes it easy to operate on
* query parameters independent of whether they are partitioned.
*/
getQueryParamAsPartition(queryParamName) {
const queryParam = this.getQueryParamGroup().get(queryParamName);
if (!queryParam) {
throw new Error(`Could not find query param with name ${queryParamName}. Did you forget to add it to your QueryParamGroup?`);
}
return this.wrapIntoPartition(queryParam);
}
/**
* Wraps a query parameter into a partition if it isn't already.
*/
wrapIntoPartition(queryParam) {
if (queryParam instanceof PartitionedQueryParam) {
return queryParam;
}
return new PartitionedQueryParam([queryParam], {
partition: value => [value],
reduce: values => values[0],
});
}
getQueryParamGroup() {
if (!this.queryParamGroup) {
throw new Error(`No QueryParamGroup has been registered yet.`);
}
return this.queryParamGroup;
}
}
QueryParamGroupService.decorators = [
{ type: Injectable }
];
QueryParamGroupService.ctorParameters = () => [
{ type: undefined, decorators: [{ type: Inject, args: [NGQP_ROUTER_ADAPTER,] }] },
{ type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [NGQP_ROUTER_OPTIONS,] }] }
];
/** @ignore */
const NGQP_DEFAULT_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DefaultControlValueAccessorDirective),
multi: true
};
/** @ignore */
function isAndroid(navigator) {
return /android (\d+)/.test(navigator.userAgent.toLowerCase());
}
/** @ignore */
class DefaultControlValueAccessorDirective {
constructor(platformId, renderer, elementRef) {
this.platformId = platformId;
this.renderer = renderer;
this.elementRef = elementRef;
this.composing = false;
this.fnChange = (_) => { };
this.fnTouched = () => { };
this.supportsComposition = isPlatformBrowser(this.platformId || '') && !isAndroid(window.navigator);
}
onInput(event) {
if (this.supportsComposition && this.composing) {
return;
}
this.fnChange(event.target.value);
}
onBlur() {
this.fnTouched();
}
onCompositionStart() {
this.composing = true;
}
onCompositionEnd(event) {
this.composing = false;
if (this.supportsComposition) {
this.fnChange(event.target.value);
}
}
writeValue(value) {
const normalizedValue = value === null ? '' : value;
this.renderer.setProperty(this.elementRef.nativeElement, 'value', normalizedValue);
}
registerOnChange(fn) {
this.fnChange = fn;
}
registerOnTouched(fn) {
this.fnTouched = fn;
}
setDisabledState(isDisabled) {
this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
}
}
DefaultControlValueAccessorDirective.decorators = [
{ type: Directive, args: [{
selector: 'input:not([type=checkbox]):not([type=radio])[queryParamName],textarea[queryParamName],' +
'input:not([type=checkbox]):not([type=radio])[queryParam],textarea[queryParam]',
providers: [NGQP_DEFAULT_VALUE_ACCESSOR],
},] }
];
DefaultControlValueAccessorDirective.ctorParameters = () => [
{ type: String, decorators: [{ type: Optional }, { type: Inject, args: [PLATFORM_ID,] }] },
{ type: Renderer2 },
{ type: ElementRef }
];
DefaultControlValueAccessorDirective.propDecorators = {
onInput: [{ type: HostListener, args: ['input', ['$event'],] }],
onBlur: [{ type: HostListener, args: ['blur',] }],
onCompositionStart: [{ type: HostListener, args: ['compositionstart',] }],
onCompositionEnd: [{ type: HostListener, args: ['compositionend', ['$event'],] }]
};
/** @ignore */
const NGQP_RANGE_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RangeControlValueAccessorDirective),
multi: true
};
/** @ignore */
class RangeControlValueAccessorDirective {
constructor(renderer, elementRef) {
this.renderer = renderer;
this.elementRef = elementRef;
this.fnChange = (_) => { };
this.fnTouched = () => { };
}
onInput(event) {
const value = event.target.value;
this.fnChange(value === '' ? null : parseFloat(value));
}
onBlur() {
this.fnTouched();
}
writeValue(value) {
this.renderer.setProperty(this.elementRef.nativeElement, 'value', parseFloat(value));
}
registerOnChange(fn) {
this.fnChange = fn;
}
registerOnTouched(fn) {
this.fnTouched = fn;
}
setDisabledState(isDisabled) {
this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
}
}
RangeControlValueAccessorDirective.decorators = [
{ type: Directive, args: [{
selector: 'input[type=range][queryParamName],input[type=range][queryParam]',
providers: [NGQP_RANGE_VALUE_ACCESSOR],
},] }
];
RangeControlValueAccessorDirective.ctorParameters = () => [
{ type: Renderer2 },
{ type: ElementRef }
];
RangeControlValueAccessorDirective.propDecorators = {
onInput: [{ type: HostListener, args: ['input', ['$event'],] }],
onBlur: [{ type: HostListener, args: ['blur',] }]
};
/** @ignore */
const NGQP_NUMBER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NumberControlValueAccessorDirective),
multi: true
};
/** @ignore */
class NumberControlValueAccessorDirective {
constructor(renderer, elementRef) {
this.renderer = renderer;
this.elementRef = elementRef;
this.fnChange = (_) => { };
this.fnTouched = () => { };
}
onInput(event) {
const value = event.target.value;
this.fnChange(value === '' ? null : parseFloat(value));
}
onBlur() {
this.fnTouched();
}
writeValue(value) {
const normalizedValue = value === null ? '' : value;
this.renderer.setProperty(this.elementRef.nativeElement, 'value', normalizedValue);
}
registerOnChange(fn) {
this.fnChange = fn;
}
registerOnTouched(fn) {
this.fnTouched = fn;
}
setDisabledState(isDisabled) {
this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
}
}
NumberControlValueAccessorDirective.decorators = [
{ type: Directive, args: [{
selector: 'input[type=number][queryParamName],input[type=number][queryParam]',
providers: [NGQP_NUMBER_VALUE_ACCESSOR],
},] }
];
NumberControlValueAccessorDirective.ctorParameters = () => [
{ type: Renderer2 },
{ type: ElementRef }
];
NumberControlValueAccessorDirective.propDecorators = {
onInput: [{ type: HostListener, args: ['input', ['$event'],] }],
onBlur: [{ type: HostListener, args: ['blur',] }]
};
/** @ignore */
const NGQP_CHECKBOX_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CheckboxControlValueAccessorDirective),
multi: true
};
/** @ignore */
class CheckboxControlValueAccessorDirective {
constructor(renderer, elementRef) {
this.renderer = renderer;
this.elementRef = elementRef;
this.fnChange = (_) => { };
this.fnTouched = () => { };
}
onInput(event) {
this.fnChange(event.target.checked);
}
onBlur() {
this.fnTouched();
}
writeValue(value) {
this.renderer.setProperty(this.elementRef.nativeElement, 'checked', value);
}
registerOnChange(fn) {
this.fnChange = fn;
}
registerOnTouched(fn) {
this.fnTouched = fn;
}
setDisabledState(isDisabled) {
this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
}
}
CheckboxControlValueAccessorDirective.decorators = [
{ type: Directive, args: [{
selector: 'input[type=checkbox][queryParamName],input[type=checkbox][queryParam]',
providers: [NGQP_CHECKBOX_VALUE_ACCESSOR],
},] }
];
CheckboxControlValueAccessorDirective.ctorParameters = () => [
{ type: Renderer2 },
{ type: ElementRef }
];
CheckboxControlValueAccessorDirective.propDecorators = {
onInput: [{ type: HostListener, args: ['change', ['$event'],] }],
onBlur: [{ type: HostListener, args: ['blur',] }]
};
/** @ignore */
const NGQP_SELECT_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectControlValueAccessorDirective),
multi: true
};
/** @ignore */
class SelectControlValueAccessorDirective {
constructor(renderer, elementRef) {
this.renderer = renderer;
this.elementRef = elementRef;
this.value = null;
this.selectedId = null;
this.optionMap = new Map();
this.idCounter = 0;
this.fnChange = (_) => { };
this.fnTouched = () => { };
}
onChange(event) {
this.selectedId = event.target.value;
this.value = undefinedToNull(this.optionMap.get(this.selectedId));
this.fnChange(this.value);
}
onTouched() {
this.fnTouched();
}
writeValue(value) {
this.value = value;
this.selectedId = value === null ? null : this.getOptionId(value);
if (this.selectedId === null) {
this.renderer.setProperty(this.elementRef.nativeElement, 'selectedIndex', -1);
}
this.renderer.setProperty(this.elementRef.nativeElement, 'value', this.selectedId);
}
registerOnChange(fn) {
this.fnChange = fn;
}
registerOnTouched(fn) {
this.fnTouched = fn;
}
setDisabledState(isDisabled) {
this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
}
registerOption() {
return (this.idCounter++).toString();
}
deregisterOption(id) {
this.optionMap.delete(id);
}
updateOptionValue(id, value) {
this.optionMap.set(id, value);
if (this.selectedId === id) {
this.fnChange(value);
}
}
getOptionId(value) {
for (const id of Array.from(this.optionMap.keys())) {
if (this.optionMap.get(id) === value) {
return id;
}
}
return null;
}
}
SelectControlValueAccessorDirective.decorators = [
{ type: Directive, args: [{
selector: 'select:not([multiple])[queryParamName],select:not([multiple])[queryParam]',
providers: [NGQP_SELECT_VALUE_ACCESSOR],
},] }
];
SelectControlValueAccessorDirective.ctorParameters = () => [
{ type: Renderer2 },
{ type: ElementRef }
];
SelectControlValueAccessorDirective.propDecorators = {
onChange: [{ type: HostListener, args: ['change', ['$event'],] }],
onTouched: [{ type: HostListener, args: ['blur',] }]
};
/** @ignore */
const NGQP_MULTI_SELECT_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MultiSelectControlValueAccessorDirective),
multi: true
};
/** @ignore */
class MultiSelectControlValueAccessorDirective {
constructor(renderer, elementRef) {
this.renderer = renderer;
this.elementRef = elementRef;
this.selectedIds = [];
this.options = new Map();
this.optionMap = new Map();
this.idCounter = 0;
this.fnChange = (_) => { };
this.fnTouched = () => { };
}
onChange() {
this.selectedIds = Array.from(this.options.entries())
.filter(([id, option]) => option.selected)
.map(([id]) => id);
const values = this.selectedIds.map(id => this.optionMap.get(id));
this.fnChange(values);
}
onTouched() {
this.fnTouched();
}
writeValue(values) {
values = values === null ? [] : values;
if (!Array.isArray(values)) {
throw new Error(`Provided a non-array value to select[multiple]: ${values}`);
}
this.selectedIds = values
.map(value => this.getOptionId(value))
.filter((id) => id !== null);
this.options.forEach((option, id) => option.selected = this.selectedIds.includes(id));
}
registerOnChange(fn) {
this.fnChange = fn;
}
registerOnTouched(fn) {
this.fnTouched = fn;
}
setDisabledState(isDisabled) {
this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled);
}
registerOption(option) {
const newId = (this.idCounter++).toString();
this.options.set(newId, option);
return newId;
}
deregisterOption(id) {
this.optionMap.delete(id);
}
updateOptionValue(id, value) {
this.optionMap.set(id, value);
if (this.selectedIds.includes(id)) {
this.onChange();
}
}
getOptionId(value) {
for (const id of Array.from(this.optionMap.keys())) {
if (this.optionMap.get(id) === value) {
return id;
}
}
return null;
}
}
MultiSelectControlValueAccessorDirective.decorators = [
{ type: Directive, args: [{
selector: 'select[multiple][queryParamName],select[multiple][queryParam]',
providers: [NGQP_MULTI_SELECT_VALUE_ACCESSOR],
},] }
];
MultiSelectControlValueAccessorDirective.ctorParameters = () => [
{ type: Renderer2 },
{ type: ElementRef }
];
MultiSelectControlValueAccessorDirective.propDecorators = {
onChange: [{ type: HostListener, args: ['change',] }],
onTouched: [{ type: HostListener, args: ['blur',] }]
};