4

I've built an angular 9 app, and added localization with @ngx-translate. I've configured my app so that it takes the lang query parameter and changes the locale accordingly.

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, OnDestroy {
  constructor(private route: ActivatedRoute, private translateService: TranslateService) {
    this.translateService.setDefaultLang('en');
    this.route.queryParamMap.subscribe((params) => {
      let lang = params.get('lang');
      console.log('language', lang);
      if (lang !== null) {
        this.translateService.use(lang);
      }
    });
  }
}

I then added 3 buttons on my sidebar to change the query parameter (and switch the language)

<div class="p-1 text-center">
  <a [routerLink]='[]' [queryParams]="{}">
    <app-flag [country]="'en'" [appHoverClass]="'brightness-250'"></app-flag>
  </a>
  <a [routerLink]='[]' [queryParams]="{'lang':'nl'}">
    <app-flag [country]="'nl'" [appHoverClass]="'brightness-250'"></app-flag>
  </a>
  <a [routerLink]='[]' [queryParams]="{'lang':'fr'}">
    <app-flag [country]="'fr'" [appHoverClass]="'brightness-250'"></app-flag>
  </a>
</div>

This is working fine. But when a normal routerLink is pressed, or at a router.navigate() call, the query parameters are lost again.

I don't want to decorate each and every routerLink in my application with the [queryParamsHandling]="'preserve'" directive since this is a tedious job and horrible practice. There is already a GitHub issue active for this topic, but the angular team is pretty much not working on it (for 4 years already): https://github.com/angular/angular/issues/12664

Is there a way (any way) to have the query parameters (or just the lang query parameter) preserved by default when navigating?

I've already created an ExtendedRouter on top of the default angular router

import { Router, QueryParamsHandling, NavigationExtras, UrlTree } from '@angular/router';

export class ExtendedRouter {
  constructor(private router: Router) {
  }

  private _defaultQueryParamsHandling: QueryParamsHandling = null;
  public get defaultQueryParamsHandling() {
    return this._defaultQueryParamsHandling;
  }
  public set defaultQueryParamsHandling(value: QueryParamsHandling) {
    this._defaultQueryParamsHandling = value;
  }

  public navigate(commands: any[], extras?: NavigationExtras) {
    return this.router.navigate(commands, {
      queryParamsHandling: extras.queryParamsHandling ?? this.defaultQueryParamsHandling ?? '',
      fragment: extras.fragment,
      preserveFragment: extras.preserveFragment,
      queryParams: extras.queryParams,
      relativeTo: extras.relativeTo,
      replaceUrl: extras.replaceUrl,
      skipLocationChange: extras.skipLocationChange
    });
  }

  public navigateByUrl(url: string | UrlTree, extras?: NavigationExtras) {
    return this.router.navigateByUrl(url, {
      queryParamsHandling: extras.queryParamsHandling ?? this.defaultQueryParamsHandling ?? '',
      fragment: extras.fragment,
      preserveFragment: extras.preserveFragment,
      queryParams: extras.queryParams,
      relativeTo: extras.relativeTo,
      replaceUrl: extras.replaceUrl,
      skipLocationChange: extras.skipLocationChange
    });
  }

  public createUrlTree(commands: any[], extras?: NavigationExtras) {
    return this.router.createUrlTree(commands, extras);
  }

  public serializeUrl(url: UrlTree) {
    return this.router.serializeUrl(url);
  }
}

But this doesn't deal with the [routerLink] directive. I've tried creating one as well, but all fields I need are scoped to private.

import { Directive, Renderer2, ElementRef, Attribute, Input } from '@angular/core';
import { RouterLink, Router, ActivatedRoute } from '@angular/router';
import { ExtendedRouter } from '../../helpers/extended-router';

@Directive({
  selector: '[extendedRouterLink]'
})
export class ExtendedRouterLinkDirective extends RouterLink {

  private router2: Router;
  private route2: ActivatedRoute;
  private commands2: any[] = [];
  constructor(router: Router, route: ActivatedRoute, @Attribute('tabindex') tabIndex: string, renderer: Renderer2, el: ElementRef<any>, private extendedRouter: ExtendedRouter) {
    super(router, route, tabIndex, renderer, el);
    this.router2 = router;
    this.route2 = route;
  }

  @Input()
  set extendedRouterLink(commands: any[] | string | null | undefined) {
    if (commands != null) {
      this.commands2 = Array.isArray(commands) ? commands : [commands];
    } else {
      this.commands2 = [];
    }
    super.commands = commands;
  }

  get urlTree() {
    return this.router2.createUrlTree(this.commands, {
      relativeTo: this.route2,
      queryParams: this.queryParams,
      fragment: this.fragment,
      queryParamsHandling: this.queryParamsHandling,
      preserveFragment: this.attrBoolValue(this.preserveFragment),
    });
  }

  private attrBoolValue = (s: any) => {
    return s === '' || !!s;
  }

}

Anyone an idea how to get around this without having to define a [queryParamsHandling] on each [routerLink]?

Pieterjan
  • 2,738
  • 4
  • 28
  • 55
  • 3
    This seems to be a nice solution: https://github.com/angular/angular/issues/12664#issuecomment-589980254 – Andrei Gătej May 24 '20 at 15:33
  • Indeed, I must have looked over it. I'll try it out – Pieterjan May 24 '20 at 15:41
  • Works very well indeed. Good answer – Pieterjan May 24 '20 at 16:01
  • It appears that when using angular routing, the directive is not applied to link elements in newly created components for some reason. Do you know a way to solve this? – Pieterjan May 27 '20 at 07:43
  • What does the directive's selector look like? Have you tried something like: `selector: '[routerLink]'` ? – Andrei Gătej May 27 '20 at 07:48
  • Yes. `a[routerLink]` (https://github.com/MintPlayer/MintPlayer/blob/master/MP.Web/ClientApp/src/app/directives/query-params-handling/query-params-handling.directive.ts) – Pieterjan May 27 '20 at 08:12
  • Could you create a StackBlitz demo? – Andrei Gătej May 27 '20 at 08:28
  • https://stackblitz.com/edit/angular-routing-preserve-queryparams When clicking the top links, the queryparams are preserved. When clicking the links inside the routing components, the queryparams disappear. However the url of those links seems to be correct. So it appears that the directive isn't being re-applied to the links inside the router-outlet after navigation has occurred. How can I solve this? – Pieterjan May 29 '20 at 10:18
  • Thanks for the SB. I'll have a look. – Andrei Gătej May 29 '20 at 10:19
  • 1
    I'm sorry that was the normal demo. Here's an extended version with the links inside the routing components: https://stackblitz.com/edit/angular-routing-preserve-queryparams-link-from-routing-componen – Pieterjan May 29 '20 at 10:23

3 Answers3

3

There is a small problem with this approach:


@Directive({
  selector: 'a[routerLink]'
})
export class QueryParamsHandlingDirective extends RouterLinkWithHref {
  queryParamsHandling: QueryParamsHandling = 'merge';
}

The problem is that it extends RouterLinkWithHref, meaning an <a routerLink=""> will have 2 directives(one which extends the other) attached to it.

And this is what happens inside RouterLinkWithHref's click handler:

@HostListener('click')
onClick(): boolean {
  const extras = {
    skipLocationChange: attrBoolValue(this.skipLocationChange),
    replaceUrl: attrBoolValue(this.replaceUrl),
    state: this.state,
  };
  this.router.navigateByUrl(this.urlTree, extras);
  return true;
}

What's more important is how this looks when it's shipped to the browser:

 RouterLinkWithHref.prototype.onClick = function (button, ctrlKey, metaKey, shiftKey) {
  if (button !== 0 || ctrlKey || metaKey || shiftKey) {
      return true;
  }
  if (typeof this.target === 'string' && this.target != '_self') {
      return true;
  }
  var extras = {
      skipLocationChange: attrBoolValue(this.skipLocationChange),
      replaceUrl: attrBoolValue(this.replaceUrl),
      state: this.state
  };
  this.router.navigateByUrl(this.urlTree, extras);
  return false;
};

This means that when you click on an <a> tag, the QueryParamsHandlingDirective.onClick will be invoked, and then RouterLinkWithHref.onClick. But since RouterLinkWithHref.onClick is called last, it won't have the queryParamsHandling set to merge.


The solution is to slightly modify the custom directive so that it does not inherit anything, but simply sets a property:

@Directive({
  selector: 'a[routerLink]'
})
export class QueryParamsHandlingDirective {
  constructor (routerLink: RouterLinkWithHref) {
    routerLink.queryParamsHandling = 'merge';
  }
}

StackBlitz.

Andrei Gătej
  • 11,116
  • 1
  • 14
  • 31
0

You could wrap the router.navigate() into a utility class with a method that takes as parameters the router itself and what you want it to do (maybe with optional parameters/default values, or pass it an object) and adds every time by default the queryParamsHandling.

Balastrong
  • 4,336
  • 2
  • 12
  • 31
  • 1
    That's where I use my `ExtendedRouter` class, but I cannot deal with the `routerLink` directives. That's where I'm stuck... – Pieterjan May 24 '20 at 15:30
0

In the end I needed a more flexible approach where I could preserve one queryparameter (lang) but drop another (return-url). So eventually I decided to go ahead and write my own AdvancedRouter and advRouterLink.

Provider that lets you setup your configuration:

import { InjectionToken } from "@angular/core";
import { QueryParamsConfig } from "../../interfaces/query-params-config";

export const QUERY_PARAMS_CONFIG = new InjectionToken<QueryParamsConfig>('QueryParamsConfig');

Interface for the value of the provider:

import { QueryParamsHandling } from "@angular/router";

export interface QueryParamsConfig {
    [key: string]: QueryParamsHandling;
}

Configure this provider in the AppModule:

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    AdvancedRouterModule
  ],
  providers: [{
    provide: QUERY_PARAMS_CONFIG, useValue: <QueryParamsConfig>{
      'lang': 'preserve',
      'return': ''
    }
  }],
  bootstrap: [AppComponent]
})
export class AppModule { }

AdvancedRouter

import { Inject, Injectable } from '@angular/core';
import { HttpParams } from '@angular/common/http';
import { ActivatedRoute, NavigationBehaviorOptions, NavigationExtras, Params, Router, UrlCreationOptions, UrlTree } from '@angular/router';
import { QueryParamsConfig } from '../../interfaces/query-params-config';
import { QUERY_PARAMS_CONFIG } from '../../providers';
import { UrlWithQueryParams } from './url-with-query-params';

@Injectable({
  providedIn: 'root'
})
export class AdvancedRouter {

  constructor(private router: Router, private route: ActivatedRoute, @Inject(QUERY_PARAMS_CONFIG) private queryParamsConfig: QueryParamsConfig) {
  }

  public navigate(commands: any[], extras?: NavigationExtras) {
    const newParams = this.computeQueryParameters(this.route.snapshot.queryParams, extras?.queryParams);
    return this.router.navigate(commands, { ...extras, queryParams: newParams });
  }

  public navigateByUrl(url: string | UrlTree, extras?: NavigationBehaviorOptions) {
    // The requested url.
    let urlValue = url instanceof UrlTree
      ? this.router.serializeUrl(url)
      : url;

    // The requested query parameters.
    const requestedParams = this.extractQueryParametersFromUrl(urlValue);

    // Use the current queryparams and requested queryparams
    // to compute the new parameters according to the configuration.
    const newParams = this.computeQueryParameters(this.route.snapshot.queryParams, requestedParams.queryParams);
    const newParamKeys = Object.keys(newParams).filter(key => !['encoder', 'map'].includes(key));
    const newQueryString = newParamKeys.map(key => `${key}=${newParams[key]}`).join('&');

    const newUrl = newParamKeys.length === 0
      ? requestedParams.url
      : `${requestedParams.url}?${newQueryString}`;

    return this.router.navigateByUrl(newUrl, extras);
  }
  
  public createUrlTree(commands: any[], extras?: UrlCreationOptions) {
    const newParams = this.computeQueryParameters(this.route.snapshot.queryParams, extras?.queryParams);
    return this.router.createUrlTree(commands, { ...extras, queryParams: newParams });
  }

  public serializeUrl(url: UrlTree) {
    return this.router.serializeUrl(url);
  }

  private extractQueryParametersFromUrl(url: string) : UrlWithQueryParams {
    if (url.includes('?')) {
      const parts = url.split('?');
      return {
        url: parts[0],
        queryParams: new HttpParams({ fromString: parts[1] })
      };
    } else {
      return {
        url: url,
        queryParams: new HttpParams()
      };
    }
  }

  private containsKey(params: Params, key: string) {
    return Object.keys(params).indexOf(key) > -1;
  }

  private computeQueryParameters(currentParams: Params, requestedParams: Params | null | undefined) {
    // Allow a null object to be passed to this method.
    const newRequestedParams = requestedParams ?? { };

    // Merge the set of keys.
    const allParamKeys = Object.keys({
      ...currentParams,
      ...newRequestedParams
    });

    return <Params>Object.assign({}, ...allParamKeys.map(k => {
        // Compute new value for each Query parameter.
        return {
          key: k,
          value: this.getQueryParameterValue(currentParams, newRequestedParams, k)
        };
      })
      // Remove query parameters to drop.
      .filter(p => p.value !== null)
      // ToDictionary
      .map(p => {
        return { [p.key] : p.value };
      })
    );
  }

  private getQueryParameterValue(currentParams: Params, requestedParams: Params, key: string) {
    switch (this.queryParamsConfig[key]) {
      case 'preserve':
        // Take requested value if present, else take current.

        // Must use containsKey since one may want to explicitly pass a null value for a specific parameter,
        // in order to drop the query parameter specified.
        return Object.keys(requestedParams).indexOf(key) === -1
          ? currentParams[key]
          : requestedParams[key];
      case 'merge':
        if (this.containsKey(currentParams, key)) {
          if (this.containsKey(requestedParams, key)) {
            // Query parameter present in both. Merge both values.
            return `${currentParams[key]},${requestedParams[key]}`;
          } else {
            // Query parameter only present in activated route.
            return currentParams[key];
          }
        } else {
          if (this.containsKey(requestedParams, key)) {
            // Query parameter only present in requested list.
            return requestedParams[key];
          } else {
            // Never occurs
          }
        }
        break;
      default:
        // Default is drop query parameter.
        if (this.containsKey(requestedParams, key)) {
          // If still present in requested list, return this value.
          return requestedParams[key];
        } else {
          // Drop query parameter.
          return null;
        }
    }
  }
}

UrlWithQueryParams

import { Params } from "@angular/router";

export interface UrlWithQueryParams {
    url: string;
    queryParams: Params;
}

AdvancedRouterLinkDirective

import { LocationStrategy } from '@angular/common';
import { Directive, Input } from '@angular/core';
import { ActivatedRoute, Router, RouterLinkWithHref, UrlTree } from '@angular/router';
import { AdvancedRouter } from '../../services/advanced-router/advanced-router';

// See https://github.com/angular/angular/blob/master/packages/router/src/directives/router_link.ts#L256

@Directive({selector: 'a[advRouterLink],area[advRouterLink]'})
export class AdvancedRouterLinkDirective extends RouterLinkWithHref {

  constructor(
    private advancedRouter: AdvancedRouter,
    private nativeRoute: ActivatedRoute,
    nativeRouter: Router,
    nativeLocationStrategy: LocationStrategy
  ) {
    super(nativeRouter, nativeRoute, nativeLocationStrategy);
  }

  private nativeCommands: any[] = [];

   @Input()
   set advRouterLink(commands: any[] | string | null | undefined) {
     if (commands != null) {
       this.nativeCommands = Array.isArray(commands) ? commands : [commands];
     } else {
       this.nativeCommands = [];
     }
   }

  get urlTree(): UrlTree {
    return this.advancedRouter.createUrlTree(this.nativeCommands, {
      relativeTo: this.relativeTo !== undefined ? this.relativeTo : this.nativeRoute,
      queryParams: this.queryParams,
      fragment: this.fragment,
      queryParamsHandling: '', // Drop queryparams and let the AdvancedRouter do all the work
      preserveFragment: this.attrBoolValue(this.preserveFragment),
    });
  }
  
  private attrBoolValue(s: any) {
    return s === '' || !!s;
  }
}
Pieterjan
  • 2,738
  • 4
  • 28
  • 55