query-param-group.service.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. import { Inject, Injectable, isDevMode, OnDestroy, Optional } from '@angular/core';
  2. import { Params } from '@angular/router';
  3. import { EMPTY, from, Observable, Subject } from 'rxjs';
  4. import {
  5. catchError,
  6. concatMap,
  7. debounceTime,
  8. distinctUntilChanged,
  9. filter,
  10. map,
  11. startWith,
  12. switchMap,
  13. takeUntil,
  14. tap
  15. } from 'rxjs/operators';
  16. import { compareParamMaps, filterParamMap, isMissing, isPresent, NOP } from '../util';
  17. import { Unpack } from '../types';
  18. import { QueryParamGroup } from '../model/query-param-group';
  19. import { QueryParam } from '../model/query-param';
  20. import {
  21. NGQP_ROUTER_ADAPTER,
  22. NGQP_ROUTER_OPTIONS,
  23. RouterAdapter,
  24. RouterOptions
  25. } from '../router-adapter/router-adapter.interface';
  26. import { QueryParamAccessor } from './query-param-accessor.interface';
  27. /** @internal */
  28. function isMultiQueryParam<T>(
  29. queryParam: QueryParam<T> | QueryParam<T[]>
  30. ): queryParam is QueryParam<T[]> {
  31. return queryParam.multi;
  32. }
  33. /** @internal */
  34. function hasArrayValue<T>(
  35. queryParam: QueryParam<T> | QueryParam<T[]>,
  36. value: T | T[]
  37. ): value is T[] {
  38. return isMultiQueryParam(queryParam);
  39. }
  40. /** @internal */
  41. function hasArraySerialization(
  42. queryParam: QueryParam<any>,
  43. values: string | string[] | null
  44. ): values is string[] {
  45. return isMultiQueryParam(queryParam);
  46. }
  47. /** @internal */
  48. class NavigationData {
  49. constructor(public params: Params, public synthetic: boolean = false) {}
  50. }
  51. /**
  52. * Service implementing the synchronization logic
  53. *
  54. * This service is the key to the synchronization process by binding a {@link QueryParamGroup}
  55. * to the router.
  56. *
  57. * @internal
  58. */
  59. @Injectable()
  60. export class QueryParamGroupService implements OnDestroy {
  61. /** The {@link QueryParamGroup} to bind. */
  62. private queryParamGroup: QueryParamGroup;
  63. /** List of {@link QueryParamAccessor} registered to this service. */
  64. private directives = new Map<string, QueryParamAccessor[]>();
  65. /**
  66. * Queue of navigation parameters
  67. *
  68. * A queue is used for navigations as we need to make sure all parameter changes
  69. * are executed in sequence as otherwise navigations might overwrite each other.
  70. */
  71. private queue$ = new Subject<NavigationData>();
  72. /** @ignore */
  73. private synchronizeRouter$ = new Subject<void>();
  74. /** @ignore */
  75. private destroy$ = new Subject<void>();
  76. constructor(
  77. @Inject(NGQP_ROUTER_ADAPTER) private routerAdapter: RouterAdapter,
  78. @Optional() @Inject(NGQP_ROUTER_OPTIONS) private globalRouterOptions: RouterOptions
  79. ) {
  80. this.setupNavigationQueue();
  81. }
  82. /** @ignore */
  83. public ngOnDestroy() {
  84. this.destroy$.next();
  85. this.destroy$.complete();
  86. this.synchronizeRouter$.complete();
  87. if (this.queryParamGroup) {
  88. this.queryParamGroup._clearChangeFunctions();
  89. }
  90. }
  91. /**
  92. * Uses the given {@link QueryParamGroup} for synchronization.
  93. */
  94. public setQueryParamGroup(queryParamGroup: QueryParamGroup): void {
  95. // FIXME: If this is called when we already have a group, we probably need to do
  96. // some cleanup first.
  97. if (this.queryParamGroup) {
  98. throw new Error(
  99. `A QueryParamGroup has already been setup. Changing the group is currently not supported.`
  100. );
  101. }
  102. this.queryParamGroup = queryParamGroup;
  103. this.startSynchronization();
  104. }
  105. /**
  106. * Registers a {@link QueryParamAccessor}.
  107. */
  108. public registerQueryParamDirective(directive: QueryParamAccessor): void {
  109. // Capture the name here, particularly for the queue below to avoid re-evaluating
  110. // it as it might change over time.
  111. const queryParamName = directive.name;
  112. const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
  113. if (!queryParam) {
  114. throw new Error(
  115. `Could not find query param with name ${queryParamName}. Did you forget to add it to your QueryParamGroup?`
  116. );
  117. }
  118. if (!directive.valueAccessor) {
  119. throw new Error(
  120. `No value accessor found for the form control. Please make sure to implement ControlValueAccessor on this component.`
  121. );
  122. }
  123. // Chances are that we read the initial route before a directive has been registered here.
  124. // The value in the model will be correct, but we need to sync it to the view once initially.
  125. directive.valueAccessor.writeValue(queryParam.value);
  126. // Proxy updates from the view to debounce them (if needed).
  127. const debouncedQueue$ = new Subject<any>();
  128. debouncedQueue$
  129. .pipe(
  130. // Do not synchronize while the param is detached from the group
  131. filter(() => !!this.queryParamGroup.get(queryParamName)),
  132. isPresent(queryParam.debounceTime) ? debounceTime(queryParam.debounceTime) : tap(),
  133. map((newValue: any) => this.getParamsForValue(queryParam, newValue)),
  134. takeUntil(this.destroy$)
  135. )
  136. .subscribe(params => this.enqueueNavigation(new NavigationData(params)));
  137. directive.valueAccessor.registerOnChange((newValue: any) => debouncedQueue$.next(newValue));
  138. this.directives.set(queryParamName, [
  139. ...(this.directives.get(queryParamName) || []),
  140. directive
  141. ]);
  142. }
  143. /**
  144. * Deregisters a {@link QueryParamAccessor} by referencing its name.
  145. */
  146. public deregisterQueryParamDirective(queryParamName: string): void {
  147. if (!queryParamName) {
  148. return;
  149. }
  150. const directives = this.directives.get(queryParamName);
  151. if (!directives) {
  152. return;
  153. }
  154. directives.forEach(directive => {
  155. directive.valueAccessor.registerOnChange(NOP);
  156. directive.valueAccessor.registerOnTouched(NOP);
  157. });
  158. this.directives.delete(queryParamName);
  159. const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
  160. if (queryParam) {
  161. queryParam._clearChangeFunctions();
  162. }
  163. }
  164. private startSynchronization() {
  165. this.setupGroupChangeListener();
  166. this.setupParamChangeListeners();
  167. this.setupRouterListener();
  168. this.watchNewParams();
  169. }
  170. /** Listens for programmatic changes on group level and synchronizes to the router. */
  171. private setupGroupChangeListener(): void {
  172. this.queryParamGroup._registerOnChange((newValue: Record<string, any>) => {
  173. let params: Params = {};
  174. Object.keys(newValue).forEach(queryParamName => {
  175. const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
  176. if (isMissing(queryParam)) {
  177. return;
  178. }
  179. params = {
  180. ...params,
  181. ...this.getParamsForValue(queryParam, newValue[queryParamName])
  182. };
  183. });
  184. this.enqueueNavigation(new NavigationData(params, true));
  185. });
  186. }
  187. /** Listens for programmatic changes on parameter level and synchronizes to the router. */
  188. private setupParamChangeListeners(): void {
  189. Object.keys(this.queryParamGroup.queryParams).forEach(queryParamName =>
  190. this.setupParamChangeListener(queryParamName)
  191. );
  192. }
  193. private setupParamChangeListener(queryParamName: string): void {
  194. const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
  195. if (!queryParam) {
  196. throw new Error(`No param in group found for name ${queryParamName}`);
  197. }
  198. queryParam._registerOnChange((newValue: any) =>
  199. this.enqueueNavigation(
  200. new NavigationData(this.getParamsForValue(queryParam, newValue), true)
  201. )
  202. );
  203. }
  204. /** Listens for changes in the router and synchronizes to the model. */
  205. private setupRouterListener(): void {
  206. this.synchronizeRouter$
  207. .pipe(
  208. startWith(undefined),
  209. switchMap(() =>
  210. this.routerAdapter.queryParamMap.pipe(
  211. // We want to ignore changes to query parameters which aren't related to this
  212. // particular group; however, we do need to react if one of our parameters has
  213. // vanished when it was set before.
  214. distinctUntilChanged((previousMap, currentMap) => {
  215. const keys = Object.values(this.queryParamGroup.queryParams).map(
  216. queryParam => queryParam.urlParam
  217. );
  218. // It is important that we filter the maps only here so that both are filtered
  219. // with the same set of keys; otherwise, e.g. removing a parameter from the group
  220. // would interfere.
  221. return compareParamMaps(
  222. filterParamMap(previousMap, keys),
  223. filterParamMap(currentMap, keys)
  224. );
  225. })
  226. )
  227. ),
  228. takeUntil(this.destroy$)
  229. )
  230. .subscribe(queryParamMap => {
  231. const synthetic = this.isSyntheticNavigation();
  232. const groupValue: Record<string, any> = {};
  233. Object.keys(this.queryParamGroup.queryParams).forEach(queryParamName => {
  234. const queryParam: QueryParam<any> = this.queryParamGroup.get(queryParamName);
  235. const newValue = queryParam.multi
  236. ? this.deserialize(queryParam, queryParamMap.getAll(queryParam.urlParam))
  237. : this.deserialize(queryParam, queryParamMap.get(queryParam.urlParam));
  238. const directives = this.directives.get(queryParamName);
  239. if (directives) {
  240. directives.forEach(directive =>
  241. directive.valueAccessor.writeValue(newValue)
  242. );
  243. }
  244. groupValue[queryParamName] = newValue;
  245. });
  246. this.queryParamGroup.setValue(groupValue, {
  247. emitEvent: !synthetic,
  248. emitModelToViewChange: false
  249. });
  250. });
  251. }
  252. /** Listens for newly added parameters and starts synchronization for them. */
  253. private watchNewParams(): void {
  254. this.queryParamGroup.queryParamAdded$
  255. .pipe(takeUntil(this.destroy$))
  256. .subscribe(queryParamName => {
  257. this.setupParamChangeListener(queryParamName);
  258. this.synchronizeRouter$.next();
  259. });
  260. }
  261. /** Returns true if the current navigation is synthetic. */
  262. private isSyntheticNavigation(): boolean {
  263. const navigation = this.routerAdapter.getCurrentNavigation();
  264. if (!navigation || navigation.trigger !== 'imperative') {
  265. // When using the back / forward buttons, the state is passed along with it, even though
  266. // for us it's now a navigation initiated by the user. Therefore, a navigation can only
  267. // be synthetic if it has been triggered imperatively.
  268. // See https://github.com/angular/angular/issues/28108.
  269. return false;
  270. }
  271. return navigation.extras && navigation.extras.state && navigation.extras.state['synthetic'];
  272. }
  273. /** Subscribes to the parameter queue and executes navigations in sequence. */
  274. private setupNavigationQueue() {
  275. this.queue$
  276. .pipe(
  277. takeUntil(this.destroy$),
  278. concatMap(data => this.navigateSafely(data))
  279. )
  280. .subscribe();
  281. }
  282. private navigateSafely(data: NavigationData): Observable<any> {
  283. return from(
  284. this.routerAdapter.navigate(data.params, {
  285. ...this.routerOptions,
  286. state: { synthetic: data.synthetic }
  287. })
  288. ).pipe(
  289. catchError((err: any) => {
  290. if (isDevMode()) {
  291. console.error(`There was an error while navigating`, err);
  292. }
  293. return EMPTY;
  294. })
  295. );
  296. }
  297. /** Sends a change of parameters to the queue. */
  298. private enqueueNavigation(data: NavigationData): void {
  299. this.queue$.next(data);
  300. }
  301. /**
  302. * Returns the full set of parameters given a value for a parameter model.
  303. *
  304. * This consists mainly of properly serializing the model value and ensuring to take
  305. * side effect changes into account that may have been configured.
  306. */
  307. private getParamsForValue<T>(queryParam: QueryParam<any>, value: T | undefined | null): Params {
  308. const newValue = this.serialize(queryParam, value);
  309. const combinedParams: Params = isMissing(queryParam.combineWith)
  310. ? {}
  311. : queryParam.combineWith(value);
  312. // Note that we list the side-effect parameters first so that our actual parameter can't be
  313. // overridden by it.
  314. return {
  315. ...(combinedParams || {}),
  316. [queryParam.urlParam]: newValue
  317. };
  318. }
  319. private serialize<T>(queryParam: QueryParam<any>, value: T): string | string[] {
  320. if (hasArrayValue(queryParam, value)) {
  321. return (value || []).map(queryParam.serialize);
  322. } else {
  323. return queryParam.serialize(value);
  324. }
  325. }
  326. private deserialize<T>(
  327. queryParam: QueryParam<T>,
  328. values: string | string[]
  329. ): Unpack<T> | Unpack<T>[] {
  330. if (hasArraySerialization(queryParam, values)) {
  331. return values.map(queryParam.deserialize);
  332. } else {
  333. return queryParam.deserialize(values);
  334. }
  335. }
  336. /**
  337. * Returns the current set of options to pass to the router.
  338. *
  339. * This merges the global configuration with the group specific configuration.
  340. */
  341. private get routerOptions(): RouterOptions {
  342. const groupOptions = this.queryParamGroup ? this.queryParamGroup.routerOptions : {};
  343. return {
  344. ...(this.globalRouterOptions || {}),
  345. ...groupOptions
  346. };
  347. }
  348. }