The easy way to customize child router based on parent router param


#1

The problem we want to resolve:

  1. in top level router, we have user page defined as {route: "user/:id",...}.
  2. in user page, we build 2nd level child router. We want to show different routes for different user type, for instance, show “admin” tab for admin user.

We have a problem here. The “User” component configureRouter() is processed before canActivate/activate(params) callbacks. This means you don’t know the user id when building the routes table in configureRouter(...).

The best you can do so far, is to mutate routes table afterwards in activate or canActivate callback. While it gets the job done, it has two issues:

  1. the navigation tabs flick because the async adding of new admin tab,
  2. you cannot hit browser “refresh” button while on “/user/1/admin” page, because the original routes table doesn’t know about the “admin” route.

The existing way is not optimal:

export class User() {
  activate(params, routerConfig) {
    this.id = params.id;
    return ajaxToGetUser(this.id).then(user => {
      if (user.isAdmin) {
        this.router.addRoute({
          route: 'admin', name: 'admin', title: 'Admin',
          nav: true, moduleId: './user/admin'
        });
        this.router.refreshNavigation();
      }
    });
  }
  configureRouter(config, router) {
    this.router = router;
    config.map([
      {
        route: '', name: 'details', title: 'Details',
        nav: true, moduleId: './user/details'
      }
    ]);
  }
}

Well, it turns out we can do it much easier. While reading the aurelia-router source code, I found this undocumented feature of child router.

The child router configureRouter(...) actually gets extra arguments passed in from navigationInstruction, the first of those extra arguments is the params of parent route!

This means you can do this, no more flick or page refresh issue:

export class User() {
  configureRouter(config, router, params) {
    this.router = router;
    this.id = params.id;
    return ajaxToGetUser(this.id).then(user => {
      let routes = [
        {
          route: '', name: 'details', title: 'Details',
          nav: true, moduleId: './user/details'
        }
      ];

      if (user.isAdmin) {
        routes.push({
          route: 'admin', name: 'admin', title: 'Admin',
          nav: true, moduleId: './user/admin'
        });
      }

      config.map(routes);
    });
  }
}

In addition, now you have user instance while build the routes table, which means you can put it in settings on all sub-routes if you want {route: '', settings: {user}, ...}.


#2

Very useful feature. It’s often painful on first go, where you don’t have parent params. Nice find @huochunpeng


#3

This should be in official router doc. I will raise a PR when I got time.


#4

I was wrong when saying you don't have parent params. Actually configureRouter will always be called after activate, and for that reason, we always have the params in parent route. One can store it in the vm and then use it. But having it in the arguments should be easier sometimes.


#5

@bigopon I am 100% sure configureRouter is called before canActivate and activate. That was where I stuck before on this kind of thing.


#6

I read the log in my application wrong, you are correct. Thanks for the clarification


#7

TIL


#8

This is a great example of a contribution in the category Tips and tricks, a planned part of the AUCS documentation set. While there are only three specific guides in the current plan, if @huochunpeng is willing to create the very first item in the (not yet announced) Tips and tricks guide, please check initial set of instructions explaining how to start AUCS project related work and get in touch with me in our Gitter chat room .


#9

@adriatic sure, I would love to contribute.


#10

Building the perfect master-detail page in Aurelia by @davismj might give food for thoughts too. I think there should be a nice example of a navigation with something like a side drawer and a main area that uses roles as described here and then a main area. This is something probably everyone needs to think of and it’d be nice to see Aurelia UX components used to do this (Drawer isn’t there yet, but perhaps using grid).


#11

We have a problem here. The “User” component configureRouter() is processed before canActivate/activate(params) callbacks. This means you don’t know the user id when building the routes table in configureRouter(…).

There is a push to dynamically add, remove, modify routes in an Aurelia application in part because that “makes sense”. If we want to have a “admin only” route, check for an admin and create the route, right? However this causes problems and sends people down long rabbit holes.

Instead, I recommend that you always define all of your routes up front. If there’s a route in your application, define it right away. There’s no reason not to. Then, use the AuthorizeStep to enable or disable access to the route based on permissions. Finally, whether using the NavModel or hard coding your routes, use templating bindings like show and if to show and hide controls for a clean and understandable user experience.


#12

Thanks @davismj. In my apps, I do use AuthorizeStep to reject, and use settings in route config to hide routes based on user roles.

I might used wrong domain object (User) to illustrate my problem. That tapped into authorization.

Let’s say the parent route is "product/:id", I actually want to display totally different child routes for physical product vs virtual product (eg. voucher). In this use case, it doesn’t make much sense to create the union of all possible routes upfront. This is the problem I had in mind.

Furthermore, the feature I demonstrated is the least demanding way for fellow programmers to implement dynamic child router. I think simplicity also has its weight on “clean and understandable”.


#13

So lets say physical products have a product/:id/shipping and virtual products have a product/:id/download. In your application, product/shipping.js and product/download.js still exist, and they are still valid routes. By virtue of this fact, it makes sense to register both of these routes up front. They are valid routes and can be loaded.

But what if product 13 is a virtual product, why let the user go to product/13/shipping? That’s what the canActivate() callback is for. Your UI should never allow the user to get there, but if they try to game the system by typing it in, your canActivate() callback can check to see “is this a physical product?” When it sees that it isn’t, it can show an error screen or popup and / or completely cancel navigation.

If you compare these two strategies, you can see that defining all your routes up front is simpler. So you would have to show that dynamically adding or removing routes gets you some meaningful benefit.


#14

@davismj not meant to start an argument. But I do have routes defined by backend, I need to dynamically define them in one way or another.

export class Plugin {
  configureRouter(config, router, params) {
    this.router = router;
    this.id = params.id;
    return ajaxToGetPlugin(this.id).then(plugin => {
      let routes = [ /* common routes */ ];

      _.each(plugin.verbs, verb => {
        routes.push({
          route: verb.name, name: verb.name, title: verb.label,
          nav: true, moduleId: './verb-form',
          settings: {formSpec: verb.formSpec}
        });
      });

      config.map(routes);
    });
  }
}

FYI, for readers interested to use above code, you need to use activationStrategy.replace to ensure activate(params, routeConfig) is always called.

export class VerbForm {
  determineActivationStrategy() {
    return activationStrategy.replace;
  }
}

#15

Just to add some clarification to the above:

  • activationStrategy.invokeLifecycle should be sufficient to ensure activate() is called.

activate() is called by default when the route params have changed (which triggers the navigation plan to set the strategy to invokeLifecycle)

  • activationStrategy.replace actually invokes the whole component lifecycle (including the templating’s bind(), attached() etc).

The navigation plan normally only sets the strategy to replace when the moduleId has changed e.g. a new viewmodel needs to be composed. Forcing the whole component lifecycle is costly and unnecessary if (can)(de)activate is really all you need.


#16

@huochunpeng your post was just published in the Router category, as article named easy way to customize child router based on parent route param. You are forever enshrined as the first contributor :smile: :clap: :clap: