canjscanjs-routing

Routing Conventions in Can.js


So I’m looking to make some routes within my super cool can.js application. Aiming for something like this…

#!claims          ClaimsController - lists claims

#!claims/:id      ClaimController - views a single claim
#!claims/new      ClaimController - creates a new claim
#!claims/:id/pdf  - do nothing, the ClaimController will handle it

#!admin           AdminController - loads my Administrative panel with menu
#!admin/users     - do nothing, the AdminController will handle it
#!admin/settings  - do nothing, the AdminController will handle it

So how might we do this?

“claims route”:      function() { load('ClaimsController'); },
“claims/:id route”:  function() { load('ClaimController'); },
“admin”:             function() { load(‘AdminController’); },

Cool beans, we’re off. So what if someone sends a link to someone like...

http://myapp#!claims/1/pdf

Nothing happens! Ok, well let’s add the route.

“claims/:id/pdf route”: function() { load('ClaimController'); },

Great. Now that link works. Here, the router’s job is only to load the controller. The controller will recognize that the pdf action is wanted, and show the correct view.

So pretend I’ve loaded up a claim claims/:id and I edit one or two things. Then I click the Print Preview button to view the PDF and change my route to claims/:id/pdf.

What should happen… the Claim Controller is watching the route and shows the pdf view.

What actually happens… the router sees the change, matches the claims/:id/pdf route we added, and reloads the Claim Controller, displaying a fresh version of the claim pulled from the server/cache, losing my changes.

To try and define the problem, I need the router to identify when the route changes, what controller the route belongs to, and if the controller is already loaded, ignore it. But this is hard!

claims      //
claims/:id // different controllers!

claims/:id      //
claims/:id/pdf // same controller!

We could just bind on the "controller" change. So defining routes like can.route(':controller') and binding on :controller.

{can.route} controller
// or
can.route.bind('controller', function() {...})

But clicking on a claim (changing from ClaimsController to ClaimController) won't trigger, as the first token claim is the same in both cases.

Is there a convention I can lean on? Should I be specifying every single route in the app and checking if the controller is loaded? Are my preferred route urls just not working?


Solution

  • The following is how I setup routing in complex CanJS applications. You can see an example of this here.

    First, do not use can.Control routes. It's an anti-pattern and will be removed in 3.0 for something like the ideas in this issue.

    Instead you setup a routing app module that imports and sets up modules by convention similar to this which is used here.

    I will explain how to setup a routing app module in a moment. But first, it's important to understand how can.route is different from how you are probably used to thinking of routing. Its difference makes it difficult to understand at first, but once you get it; you'll hopefully see how powerful and perfect it is for client-side routing.

    Instead of thinking of urls, think of can.route's data. What is in can.route.attr(). For example, your URLs seem to have data like:

    For example, admin/users might want can.route.attr() to return:

    {page: "admin", subpage: "users"}
    

    And, claims/5 might translate into:

    {page: "claims", id: "5"}
    

    When I start building an application, I only use urls that look like #!page=admin&subpage=users and ignore the pretty routing until later. I build an application around state first and foremost.

    Once I have the mental picture of the can.route.attr() data that encapsulates my application's state, I build a routing app module that listens to changes in can.route and sets up the right controls or components. Yours might look like:

    can.route.bind("change", throttle(function(){
      if( can.route.attr("page") == "admin" ) {
        load("AdminController")
      } else if(can.route.attr("page") === "claims" && can.route.attr("id") {
        load("ClaimController")
      } else if ( ... ) { 
        ... 
      } else {
        // by convention, load a controller for whatever page is
        load(can.capitalize(can.route.attr("page")+"Controller")
      }
    
    }) );
    

    Finally, after setting all of that up, I make my pretty routes map to my expected can.route.attr() values:

    can.route(":page"); // for #!claims, #!admin
    can.route("claims/new", {page: "claims", subpage: "new"});
    can.route("claims/:id", {page: "claims"});
    can.route("admin/:subpage",{page: "admin"});
    

    By doing it this way, you keep your routes independent of rest of the application. Everything simply listens to changes in can.route's attributes. All your routing rules are maintained in one place.