File

test/fixtures/sample-files/query-param-group.service.ts

Description

Service implementing the synchronization logic

This service is the key to the synchronization process by binding a QueryParamGroup to the router.

Index

Properties
Methods
Accessors

Constructor

constructor(routerAdapter: RouterAdapter, globalRouterOptions: RouterOptions)
Parameters :
Name Type Optional
routerAdapter RouterAdapter No
globalRouterOptions RouterOptions No

Methods

Public deregisterQueryParamDirective
deregisterQueryParamDirective(queryParamName: string)

Deregisters a QueryParamAccessor by referencing its name.

Parameters :
Name Type Optional
queryParamName string No
Returns : void
Private deserialize
deserialize(queryParam: QueryParam, values: string | string[])
Type parameters :
  • T
Parameters :
Name Type Optional
queryParam QueryParam<T> No
values string | string[] No
Returns : Unpack | []
Private enqueueNavigation
enqueueNavigation(data: NavigationData)

Sends a change of parameters to the queue.

Parameters :
Name Type Optional
data NavigationData No
Returns : void
Private getParamsForValue
getParamsForValue(queryParam: QueryParam, value: T | undefined | null)
Type parameters :
  • T

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.

Parameters :
Name Type Optional
queryParam QueryParam<any> No
value T | undefined | null No
Returns : Params
Private isSyntheticNavigation
isSyntheticNavigation()

Returns true if the current navigation is synthetic.

Returns : boolean
Private navigateSafely
navigateSafely(data: NavigationData)
Parameters :
Name Type Optional
data NavigationData No
Returns : Observable<any>
Public registerQueryParamDirective
registerQueryParamDirective(directive: QueryParamAccessor)

Registers a QueryParamAccessor.

Parameters :
Name Type Optional
directive QueryParamAccessor No
Returns : void
Private serialize
serialize(queryParam: QueryParam, value: T)
Type parameters :
  • T
Parameters :
Name Type Optional
queryParam QueryParam<any> No
value T No
Returns : string | []
Public setQueryParamGroup
setQueryParamGroup(queryParamGroup: QueryParamGroup)

Uses the given QueryParamGroup for synchronization.

Parameters :
Name Type Optional
queryParamGroup QueryParamGroup No
Returns : void
Private setupGroupChangeListener
setupGroupChangeListener()

Listens for programmatic changes on group level and synchronizes to the router.

Returns : void
Private setupNavigationQueue
setupNavigationQueue()

Subscribes to the parameter queue and executes navigations in sequence.

Returns : void
Private setupParamChangeListener
setupParamChangeListener(queryParamName: string)
Parameters :
Name Type Optional
queryParamName string No
Returns : void
Private setupParamChangeListeners
setupParamChangeListeners()

Listens for programmatic changes on parameter level and synchronizes to the router.

Returns : void
Private setupRouterListener
setupRouterListener()

Listens for changes in the router and synchronizes to the model.

Returns : void
Private startSynchronization
startSynchronization()
Returns : void
Private watchNewParams
watchNewParams()

Listens for newly added parameters and starts synchronization for them.

Returns : void

Properties

Private directives
Type : unknown
Default value : new Map<string, QueryParamAccessor[]>()

List of QueryParamAccessor registered to this service.

Private queryParamGroup
Type : QueryParamGroup

The QueryParamGroup to bind.

Private queue$
Type : unknown
Default value : new Subject<NavigationData>()

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.

Accessors

routerOptions
getrouterOptions()

Returns the current set of options to pass to the router.

This merges the global configuration with the group specific configuration.

Returns : RouterOptions
import { Inject, Injectable, isDevMode, OnDestroy, Optional } from '@angular/core';
import { Params } from '@angular/router';
import { EMPTY, from, Observable, Subject } from 'rxjs';
import {
    catchError,
    concatMap,
    debounceTime,
    distinctUntilChanged,
    filter,
    map,
    startWith,
    switchMap,
    takeUntil,
    tap
} from 'rxjs/operators';
import { compareParamMaps, filterParamMap, isMissing, isPresent, NOP } from '../util';
import { Unpack } from '../types';
import { QueryParamGroup } from '../model/query-param-group';
import { QueryParam } from '../model/query-param';
import {
    NGQP_ROUTER_ADAPTER,
    NGQP_ROUTER_OPTIONS,
    RouterAdapter,
    RouterOptions
} from '../router-adapter/router-adapter.interface';
import { QueryParamAccessor } from './query-param-accessor.interface';

/** @internal */
function isMultiQueryParam<T>(
    queryParam: QueryParam<T> | QueryParam<T[]>
): queryParam is QueryParam<T[]> {
    return queryParam.multi;
}

/** @internal */
function hasArrayValue<T>(
    queryParam: QueryParam<T> | QueryParam<T[]>,
    value: T | T[]
): value is T[] {
    return isMultiQueryParam(queryParam);
}

/** @internal */
function hasArraySerialization(
    queryParam: QueryParam<any>,
    values: string | string[] | null
): values is string[] {
    return isMultiQueryParam(queryParam);
}

/** @internal */
class NavigationData {
    constructor(public params: Params, public synthetic: boolean = false) {}
}

/**
 * Service implementing the synchronization logic
 *
 * This service is the key to the synchronization process by binding a {@link QueryParamGroup}
 * to the router.
 *
 * @internal
 */
@Injectable()
export class QueryParamGroupService implements OnDestroy {
    /** The {@link QueryParamGroup} to bind. */
    private queryParamGroup: QueryParamGroup;

    /** List of {@link QueryParamAccessor} registered to this service. */
    private directives = new Map<string, QueryParamAccessor[]>();

    /**
     * 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.
     */
    private queue$ = new Subject<NavigationData>();

    /** @ignore */
    private synchronizeRouter$ = new Subject<void>();

    /** @ignore */
    private destroy$ = new Subject<void>();

    constructor(
        @Inject(NGQP_ROUTER_ADAPTER) private routerAdapter: RouterAdapter,
        @Optional() @Inject(NGQP_ROUTER_OPTIONS) private globalRouterOptions: RouterOptions
    ) {
        this.setupNavigationQueue();
    }

    /** @ignore */
    public ngOnDestroy() {
        this.destroy$.next();
        this.destroy$.complete();

        this.synchronizeRouter$.complete();

        if (this.queryParamGroup) {
            this.queryParamGroup._clearChangeFunctions();
        }
    }

    /**
     * Uses the given {@link QueryParamGroup} for synchronization.
     */
    public setQueryParamGroup(queryParamGroup: QueryParamGroup): void {
        // 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}.
     */
    public registerQueryParamDirective(directive: QueryParamAccessor): void {
        // 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 queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
        if (!queryParam) {
            throw new Error(
                `Could not find query param with name ${queryParamName}. Did you forget to add it to your QueryParamGroup?`
            );
        }
        if (!directive.valueAccessor) {
            throw new Error(
                `No value accessor found for the form control. Please make sure to implement ControlValueAccessor on this component.`
            );
        }

        // 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(queryParam.value);

        // Proxy updates from the view to debounce them (if needed).
        const debouncedQueue$ = new Subject<any>();
        debouncedQueue$
            .pipe(
                // Do not synchronize while the param is detached from the group
                filter(() => !!this.queryParamGroup.get(queryParamName)),

                isPresent(queryParam.debounceTime) ? debounceTime(queryParam.debounceTime) : tap(),
                map((newValue: any) => this.getParamsForValue(queryParam, newValue)),
                takeUntil(this.destroy$)
            )
            .subscribe(params => this.enqueueNavigation(new NavigationData(params)));

        directive.valueAccessor.registerOnChange((newValue: any) => debouncedQueue$.next(newValue));

        this.directives.set(queryParamName, [
            ...(this.directives.get(queryParamName) || []),
            directive
        ]);
    }

    /**
     * Deregisters a {@link QueryParamAccessor} by referencing its name.
     */
    public deregisterQueryParamDirective(queryParamName: string): void {
        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: QueryParam<any> = this.queryParamGroup.get(queryParamName);
        if (queryParam) {
            queryParam._clearChangeFunctions();
        }
    }

    private startSynchronization() {
        this.setupGroupChangeListener();
        this.setupParamChangeListeners();
        this.setupRouterListener();

        this.watchNewParams();
    }

    /** Listens for programmatic changes on group level and synchronizes to the router. */
    private setupGroupChangeListener(): void {
        this.queryParamGroup._registerOnChange((newValue: Record<string, any>) => {
            let params: Params = {};
            Object.keys(newValue).forEach(queryParamName => {
                const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
                if (isMissing(queryParam)) {
                    return;
                }

                params = {
                    ...params,
                    ...this.getParamsForValue(queryParam, newValue[queryParamName])
                };
            });

            this.enqueueNavigation(new NavigationData(params, true));
        });
    }

    /** Listens for programmatic changes on parameter level and synchronizes to the router. */
    private setupParamChangeListeners(): void {
        Object.keys(this.queryParamGroup.queryParams).forEach(queryParamName =>
            this.setupParamChangeListener(queryParamName)
        );
    }

    private setupParamChangeListener(queryParamName: string): void {
        const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
        if (!queryParam) {
            throw new Error(`No param in group found for name ${queryParamName}`);
        }

        queryParam._registerOnChange((newValue: any) =>
            this.enqueueNavigation(
                new NavigationData(this.getParamsForValue(queryParam, newValue), true)
            )
        );
    }

    /** Listens for changes in the router and synchronizes to the model. */
    private setupRouterListener(): void {
        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.queryParamGroup.queryParams).map(
                                queryParam => queryParam.urlParam
                            );

                            // 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)
                            );
                        })
                    )
                ),
                takeUntil(this.destroy$)
            )
            .subscribe(queryParamMap => {
                const synthetic = this.isSyntheticNavigation();
                const groupValue: Record<string, any> = {};

                Object.keys(this.queryParamGroup.queryParams).forEach(queryParamName => {
                    const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
                    const newValue = queryParam.multi
                        ? this.deserialize(queryParam, queryParamMap.getAll(queryParam.urlParam))
                        : this.deserialize(queryParam, queryParamMap.get(queryParam.urlParam));

                    const directives = this.directives.get(queryParamName);
                    if (directives) {
                        directives.forEach(directive =>
                            directive.valueAccessor.writeValue(newValue)
                        );
                    }

                    groupValue[queryParamName] = newValue;
                });

                this.queryParamGroup.setValue(groupValue, {
                    emitEvent: !synthetic,
                    emitModelToViewChange: false
                });
            });
    }

    /** Listens for newly added parameters and starts synchronization for them. */
    private watchNewParams(): void {
        this.queryParamGroup.queryParamAdded$
            .pipe(takeUntil(this.destroy$))
            .subscribe(queryParamName => {
                this.setupParamChangeListener(queryParamName);
                this.synchronizeRouter$.next();
            });
    }

    /** Returns true if the current navigation is synthetic. */
    private isSyntheticNavigation(): boolean {
        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. */
    private setupNavigationQueue() {
        this.queue$
            .pipe(
                takeUntil(this.destroy$),
                concatMap(data => this.navigateSafely(data))
            )
            .subscribe();
    }

    private navigateSafely(data: NavigationData): Observable<any> {
        return from(
            this.routerAdapter.navigate(data.params, {
                ...this.routerOptions,
                state: { synthetic: data.synthetic }
            })
        ).pipe(
            catchError((err: any) => {
                if (isDevMode()) {
                    console.error(`There was an error while navigating`, err);
                }

                return EMPTY;
            })
        );
    }

    /** Sends a change of parameters to the queue. */
    private enqueueNavigation(data: NavigationData): void {
        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.
     */
    private getParamsForValue<T>(queryParam: QueryParam<any>, value: T | undefined | null): Params {
        const newValue = this.serialize(queryParam, value);

        const combinedParams: Params = isMissing(queryParam.combineWith)
            ? {}
            : queryParam.combineWith(value);

        // Note that we list the side-effect parameters first so that our actual parameter can't be
        // overridden by it.
        return {
            ...(combinedParams || {}),
            [queryParam.urlParam]: newValue
        };
    }

    private serialize<T>(queryParam: QueryParam<any>, value: T): string | string[] {
        if (hasArrayValue(queryParam, value)) {
            return (value || []).map(queryParam.serialize);
        } else {
            return queryParam.serialize(value);
        }
    }

    private deserialize<T>(
        queryParam: QueryParam<T>,
        values: string | string[]
    ): Unpack<T> | Unpack<T>[] {
        if (hasArraySerialization(queryParam, values)) {
            return values.map(queryParam.deserialize);
        } else {
            return queryParam.deserialize(values);
        }
    }

    /**
     * Returns the current set of options to pass to the router.
     *
     * This merges the global configuration with the group specific configuration.
     */
    private get routerOptions(): RouterOptions {
        const groupOptions = this.queryParamGroup ? this.queryParamGroup.routerOptions : {};

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

results matching ""

    No results matching ""