UNPKG

@ngqp/core

Version:

Synchronizing form controls with the URL for Angular

1,315 lines (1,301 loc) 66.1 kB
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',] }] };