public
Last active

  • Download Gist
ember-routes.md
Markdown

Routing in Ember

In Ember, the application's state manager handles routing. Let's take a look at a simple example:

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend({
      route: "/",

      setupContext: function(manager) {
        manager.transitionTo('posts.index')
      }
    }),

    posts: Ember.State.extend({
      route: "/posts",

      setupContext: function(manager) {
        // the postsController is shared between the index and
        // show views, so set it up here
        var postsController = manager.get('postsController');

        postsController.set('content', Post.findAll());        
      },

      index: Ember.State.extend({
        route: "/",

        setupContext: function(manager) {
          // the application's main view is a ContainerView whose
          // view is bound to the manager's `currentView`
          manager.set('currentView', App.PostsView.create({
            controller: manager.get('postsController')
          }));
        },

        showPost: function(manager, event) {
          // this event was triggered from an {{action}} inside of
          // an {{#each}} loop in a template
          manager.transitionTo('show', event.context);
        }
      }),

      show: Ember.State.extend({
        route: "/:post_id",

        setupContext: function(manager, post) {
          var postsController = manager.get('postsController');
          postsController.set('selected', post);

          manager.set('currentView', App.PostView.create({
            controller: postController
          }))
        },

        serialize: function(manager, post) {
          return { "post_id": post.get('id') }
        },

        deserialize: function(manager, params) {
          return Post.find(params["post_id"]);
        }
      })
    })
  })
})

The primary goal of Ember routing is ensuring that the code that runs when you enter a state programmatically is the same as the code that runs when you enter a state through the URL.

In general, once you have set up your router, navigating through your application should automatically update the URL. Sharing that URL across sessions or through social media should result in exactly the same path through the application hierarchy.

Let's take a look at what happens in both situations with this simple example:

Scenario 1: Entering at /

When the user enters at /, the state manager will start out in the start state. It will immediately move into the start.index state. Because the route for the index state has no dynamic segments, the state manager passes no context to the setupContext event.

In this case, the index state immediately moves the state manager into the default view for this application, the posts.index state.

When the state manager transitions into the posts.index state, it generates a URL for the current state, /posts, and sets the current URL to /posts (either using hash changes or pushState as appropriate).

Entering the posts.index state will also render a list of all of the posts as the current view. Clicking on a post will trigger the state's showPost event with that post object as its context.

The showPost event will transition the state manager into the posts.show state, passing the post as its context.

In this case, the posts.show state has a dynamic segment (:post_id), so the state manager invokes the serialize method with the context. Assuming the post has an id of 45, the serialize method returns { "post_id": 45 }, which the state manager uses to generate the state's portion of the URL.

Scenario 2: Entering at /posts/45

Now that the user has navigated to a state corresponding to a URL of /posts/45, he may want to bookmark it or share it with a friend. Let's see what happens when a user enters the app at /posts/45.

First, the app will descend into the posts state, whose route matches the first part of the URL, /posts. Since the state has no dynamic segment, it invokes the setupContext method with no context, and the state sets up the postsController, exactly as before when the app programmatically entered the state.

Next, it descends into posts.show. Since posts.show has a dynamic segment, the state manager will invoke the state's deserialize method to retrieve its context.

The deserialize method returns a Post object for id 45. To finish up, the state manager invokes setupContext with the deserialized Post object.

Integration with ember-data (or other models)

NOTE: Implementation of this section is still TODO

While the above system is cool, writing a serialize and deserialize method for every state containing a dynamic segment can get somewhat repetitive.

To avoid this, you can specify a modelType on a state, and you will get default methods. For example, if you specify a modelType of App.Post, like this:

show: Ember.State.extend({
  route: "/:post_id",
  modelType: 'App.Post',

  setupContext: function(manager, context) {
    var postsController = manager.get('postsController');
    postsController.set('selected', context.post);

    manager.set('currentView', App.PostView.create({
      controller: postController
    }))
  }
})

The state manager will create the methods for you behind the scenes. You will get a state that looks like this:

show: Ember.State.extend({
  route: "/:post_id",
  modelType: 'App.Post',

  setupContext: function(manager, context) {
    var postsController = manager.get('postsController');
    postsController.set('selected', context.post);

    manager.set('currentView', App.PostView.create({
      controller: postController
    }))
  },

  serialize: function(manager, context) {
    return { "post_id": context.post.get('id') };
  },

  deserialize: function(manager, params) {
    return { "post": App.Post.find(params['post_id']) }
  }
})

That makes the full example from above look like:

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend({
      route "/",

      setupContext: function(manager) {
        manager.transitionTo('posts.index')
      }
    }),

    posts: Ember.State.extend({
      route: "/posts",

      setupContext: function(manager) {
        // the postsController is shared between the index and
        // show views, so set it up here
        var postsController = manager.get('postsController');

        postsController.set('content', Post.findAll());        
      },

      index: Ember.State.extend({
        route: "/",

        setupContext: function(manager) {
          // the application's main view is a ContainerView whose
          // view is bound to the manager's `currentView`
          manager.set('currentView', App.PostsView.create({
            controller: postsController
          }));
        },

        showPost: function(manager, event) {
          // this event was triggered from an {{action}} inside of
          // an {{#each}} loop in a template
          manager.transitionTo('show', event.context);
        }
      }),

      show: Ember.State.extend({
        route: "/:post_id",
        modelType: 'App.Post',

        setupContext: function(manager, context) {
          var postsController = manager.get('postsController');
          postsController.set('selected', context.post);

          manager.set('currentView', App.PostView.create({
            controller: postController
          }))
        }
      })
    })
  })
})

Where is this in the ember codebase, excited to check it out.

This is very close to Gordon's implementation (https://github.com/ghempton/ember-routemanager) which is the project I was tempted to use but I'm also interested in reducing the number of dependancies.

Any ETA until this branch hits master? (hint: sooner is better)

Thanks for the great job, BTW!

syntax error on line 4 :)

@rpflorence Fixed :)

So how ties that in with changing state on the JS level? Let's say there is some click handler whose action should be represented by a state change, like displaying a post. What needs to be done to activate a state?

<a {{action actionName}}>Click</a>

This will trigger the action on the state manager with the current context as the context parameter. You could also specify a target property, pointing at the view's current controller.

In your final example I think you're missing a reference.

      manager.set('currentView', App.PostsView.create({
        controller: postsController
      }));

I think this needs a line before it that grabs the postController from the manager.

This is interesting stuff. Particularly when compared to what we've developed in house over the last couple of years. Lots of similarities.

@polotek fixed! And awesome

Sweet! Can I use this now by merging the routing branch to master?

@wycats how are you thinking about handling nested routes, in terms of how views are instantiated?

Say you had this:

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend({
      route "/",

      setupContext: function(manager) {
        manager.transitionTo('posts.index')
      }
    }),

    posts: Ember.State.extend({
      route: "/posts",

      setupContext: function(manager) {
        var postsController = manager.get('postsController');

        postsController.set('content', Post.findAll());        
      },

      index: Ember.State.extend({
        route: "/",

        setupContext: function(manager) {
          manager.set('currentView', App.PostsView.create({
            controller: postsController
          }));
        },

        showPost: function(manager, event) {
          manager.transitionTo('show', event.context);
        }
      }),

      show: Ember.State.extend({
        route: "/:post_id",

        setupContext: function(manager, context) {
          var postsController = manager.get('postsController');
          postsController.set('selected', context.post);

          manager.set('currentView', App.PostView.create({
            controller: postController
          }))
        }

        comments: Ember.State.extend({
          route: "/comments",

          setupContext: function(manager) {
            var commentsController = manager.get('commentsController');

            commentsController.set('content', Comment.findAll());        
          },

          index: Ember.State.extend({
            route: "/",

            setupContext: function(manager) {
              manager.set('currentView', App.CommentsView.create({
                controller: commentsController
              }));
            },

            showComment: function(manager, event) {
              manager.transitionTo('show', event.context);
            }
          }),

          show: Ember.State.extend({
            route: "/:comment_id",

            setupContext: function(manager, context) {
              var commentsController = manager.get('commentsController');
              commentsController.set('selected', context.comment);

              manager.set('currentView', App.CommentView.create({
                controller: commentController
              }))
            }
          })
        })
      })
    })
  })
})

If you visited the urls /posts/45/comments or /posts/45/comments/10, the current implementation would instantiate the PostView as well as the CommentsView or CommentView.

The ideal would be that you have control over this somehow. So if it's a traditional admin kind of layout with a table showing one model at a time, then you'd see a list of posts, then an individual post, then a table of comments (or probably more realistic, say you had /groups/10/memberships/10) - in this case the user would only see one view (PostsView, PostView, CommentsView, CommentView). But in a more complex situation you might have popups or something, so you click on a comment in the list of comments and it pops up with one, so you'd be seeing 2 views: CommentsView and CommentView stacked on top of each other. Even more complex, you could have several iPad slide panels stacked on each other, where each level down /posts/45/comments/10 is 90% hidden behind the views above. In that case you need to instantiate all of the views.

So my question is, how are you approaching this? In the simple case, in visiting /posts/45/comments it seems like the ideal would be to only instantiate CommentsView and not PostView, but because the way the Ember.StateManager works, there's no way to avoid this in the above example. Still thinking through this, just popped in my head and wanted to get your take.

A way around this is to not use nested states like this, but instead make nested routes map to top level states.

So given this route set:

/posts
/posts/45
/posts/45/comments
/posts/45/comments/10
/posts/45/authors # users
/posts/45/authors/5
/comments
/comments/10
/users
/users/5

You could have a state machine like this:

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend(),
    posts: Ember.State.extend({
      index:  Ember.State.extend(),
      show:   Ember.State.extend()
    }),
    comments: Ember.State.extend({
      index:  Ember.State.extend(),
      show:   Ember.State.extend()
    })
  })
})

... implemented the same as the other examples:

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend({
      route "/",

      setupContext: function(manager) {
        manager.transitionTo('posts.index')
      }
    }),

    posts: Ember.State.extend({
      route: "/posts",

      setupContext: function(manager) {
        var postsController = manager.get('postsController');

        postsController.set('content', Post.findAll());        
      },

      index: Ember.State.extend({
        route: "/",

        setupContext: function(manager) {
          manager.set('currentView', App.PostsView.create({
            controller: postsController
          }));
        },

        showPost: function(manager, event) {
          manager.transitionTo('show', event.context);
        }
      }),

      show: Ember.State.extend({
        route: "/:post_id",

        setupContext: function(manager, context) {
          var postsController = manager.get('postsController');
          postsController.set('selected', context.post);

          manager.set('currentView', App.PostView.create({
            controller: postController
          }))
        }

    comments: Ember.State.extend({
      route: "/comments",

      setupContext: function(manager) {
        var commentsController = manager.get('commentsController');

        commentsController.set('content', Comment.findAll());        
      },

      index: Ember.State.extend({
        route: "/",

        setupContext: function(manager) {
          manager.set('currentView', App.CommentsView.create({
            controller: commentsController
          }));
        },

        showComment: function(manager, event) {
          manager.transitionTo('show', event.context);
        }
      }),

      show: Ember.State.extend({
        route: "/:comment_id",

        setupContext: function(manager, context) {
          var commentsController = manager.get('commentsController');
          commentsController.set('selected', context.comment);

          manager.set('currentView', App.CommentView.create({
            controller: commentController
          }))
        }
      })
    })
  })
})

Then in the commentsController or usersController or whatever, you can check if this.get('user') is present (similar to how belongs_to works in the inherited_resources gem), and customize the couple things in your view accordingly.

Otherwise, if nested routes mapped directly to nested states, the system could get overwhelmingly complex it seems.

It seems like you'd maybe need to distinguish "I am passing through this state" vs. "I am arriving at this state". If you're passing through, then you can configure whether or not you want to instantiate the view; if you're arriving, it defaults to instantiating the view.

Or another approach could be doing this:

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend(),
    posts: Ember.State.extend({
      route: '/posts',
      index: Ember.State.extend({
        route: '/'
      }),
      show: Ember.State.extend({
        route: '/:post_id'
      })
    }),
    comments: Ember.State.extend({
      route:  '/comments'
      index: Ember.State.extend({
        route: '/'
      }),
      show: Ember.State.extend({
        route: '/:comment_id'
      })
    }),
    postComments: Ember.State.extend({
      route: '/posts/:post_id/comments'
      index: Ember.State.extend({
        route: '/'
      }),
      show: Ember.State.extend({
        route: '/:comment_id'
      })
    })
  })
})

@viatropos you can just assign the re-usable sections to variables and use them to build up hierachies as complex as you want with no duplication needed:

var commentRoutes = Ember.State.extend({
  route: '/comments',
  index: Ember.State.extend({
    route: '/'
  }),
  show: Ember.State.extend({
    route: '/:comment_id'
  })
})

var postRoutes = Ember.State.extend({
  route: '/posts',
  index: Ember.State.extend({
    route: '/'
  }),
  show: Ember.State.extend({
    route: '/:post_id',
    comments: commentRoutes
  })
})

App.stateManager = Ember.StateManager.create({
  start: Ember.State.extend({
    index: Ember.State.extend(),
    posts: postRoutes,
    comments: commentRoutes
  })
})

Note that commentRoutes is used at the root and also nested within postRoutes so that satisfies building the following routes:

/posts/
/posts/1
/posts/1/comments/2
/comments
/comments/2

Nice! I'm looking forward to seeing this in stable!

setupContext has been changed to setupControllers. Definitely helps in making the intentions clearer.

This looks promising, if I'd like to kick the tires right now, how would I go about that? I tried getting the routing branch and running rake, but it failed somewhere in the middle with the following error:
C:\Work\git\emberjs>rake
Building Ember...
rake aborted!
SyntaxError: Expected identifier, string or number

Tasks: TOP => C:/Work/git/emberjs/dist/ember.prod.js => C:/Work/git/emberjs/tmp/
rake-pipeline-b86b1479df7649a4a4b2e5ef150e23928c0a0317/rake-pipeline-tmp-14/embe
r.prod.js

This code is already merged into master. Download the latest and give it a whirl.

Good stuff, but I found your later gist much more informative. https://gist.github.com/2728699

Please sign in to comment on this gist.

Something went wrong with that request. Please try again.