Skip to content

Instantly share code, notes, and snippets.

@wycats
Created May 19, 2012 02:22
Show Gist options
  • Save wycats/2728699 to your computer and use it in GitHub Desktop.
Save wycats/2728699 to your computer and use it in GitHub Desktop.

Recently, we've been working on extracting Ember conventions from applications we're working on into the framework. Our goal is to make it clearer how the parts of an Ember application work together, and how to organize and bootstrap your objects.

Routing

Routing is an important part of web applications. It allows your users to share the URL they see in their browser, and have the same things appear when their friends click on the link.

The Ember.js ecosystem has several great solutions for routing. But, since it is such an important part of most web applications, we've decided to build it right into the framework.

If you have already modeled your application state using Ember.StateManager, there are a few changes you'll need to make to enable routing. Once you've made those changes, you'll notice the browser's address bar spring to life as you start using your app—just by moving between states, Ember.js will update the URL automatically.

In order to make state managers more robust, we are now enforcing some new rules about how they are architected. Most existing state managers will probably not run afoul of these new rules, but if yours does, making changes should be relatively straightforward.

Here are the new rules:

  1. You must have a root state named root.
  2. Once your state manager has left its initial state, it must always reside in a leaf state (a state that has no child states). Interior states (states that have children) may never be the current state.

One other small change: For the state manager that you would like to control the URL, you should subclass Ember.Router instead of Ember.StateManager. This provides a hint to Ember.js about which state manager is the primary manager that should update the URL when its current state changes.

You tell Ember.js what to put in the URL by adding a route property to your states. When that state is entered, the string specified by route will be appended to the URL:

App.Router = Ember.Router.extend({
  root: Ember.State.extend({
    index: Ember.State.extend({
      route: '/',
      redirectsTo: 'calendar.index'
    }),

    calendar: Ember.State.extend({
      route: '/calendar',
      
      index: Ember.State.extend({
        route: '/'
      }),
      
      preferences: Ember.State.extend({
        route: '/preferences'
      })
    }),
    
    mail: Ember.State.extend({
      route: '/mail',
      
      index: Ember.State.extend({
        route: '/'
      }),
      
      preferences: Ember.State.extend({
        route: '/preferences'
      })
    })
  })
});

// If the user navigates to the page with the URL
// www.myapp.com/, you will start in the root.calendar.index state.
// The redirection to the calendar.index state will cause the URL
// to be updated to www.myapp.com/calendar

router.transitionTo('preferences');

// URL => www.myapp.com/calendar/preferences

router.transitionTo('mail.preferences');

// URL => www.myapp.com/mail/preferences

router.transitionTo('index');

// URL => www.myapp.com/mail

Hitting the back button will "just work," because the router has updated the URL as your app navigates through the states, and changing the URL via the back/forward buttons will cause the router to automatically navigate to the matching state.

Dynamic Segments

In real-life routing setups, URLs are often composed of static segments (like mail or posts) and dynamic segments, which represent a particular model. In other words, your URL captures not just the current state, but also the context of the current state.

For example, if you have a blog app that displays posts, you need to know not just that you are displaying a post, but which post in particular.

This information is captured in serialized form in the URL. For example a URL like /posts/1 typically means "show the Post object with an ID of 1."

Dynamic segments automatically extract this information from the URL for you, then inform your current state about what it should be displaying by sending the setupControllers event. This is your opportunity to setup any controllers used by your views to display this object.

For example, imagine the following router for displaying posts:

var router = Ember.Router.create({
  root: Ember.State.extend({
    index: Ember.State.extend({
      route: '/'
    }),
    posts: Ember.State.extend({
      route: '/posts',

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

        setupControllers: function(router, post) {
          var postController = router.get('postController');
          postController.set('content', post);
        }
      })
    })
  })
});

// If the user navigates to /, he will start in the
// root.index state.

router.transitionTo('posts.show', post)

// URL => www.myapp.com/posts/1

If the user navigates to www.myapp.com/posts/1, Ember will transition to the root.posts.show state. It will also call App.Post.find(1), and pass that Post object into the setupControllers event.

If the application transitions to root.posts.show, passing in a Post object whose id is 1, Ember will get the id of the Post object, and generate the URL /posts/1. This means that once the application enters a state, the URL it generates will automatically work for future navigations.

Bye Bye Singletons

If you're not sure how to organize and connect your objects, one easy solution is to create global singletons that you stick on your application's namespace and use throughout your application as needed.

This approach has two major problems:

  1. A tremendous amount of application logic is bound up in these interconnected singletons, making testing complicated. When an application uses singletons, high-level integration tests are often the only way to test reliably.
  2. The connections between your objects are defined in an ad-hoc, implicit way, based upon your usage of the singletons. In addition to being poor architecture, it makes it very hard for the framework to provide guidance for what objects belong where.

Now that most Ember.js applications will use a router to encapsulate their behavior, we can use the router as a coordination layer, and apply some conventions to how objects are exposed to it.

Part of this is a new mechanism for initializing your controllers. Previously, you would create a global singleton and bind your views to it:

App.postsController = Ember.ArrayController.create({
  // … your controller here
});
{{#each App.postsController}}
  <h1>{{title}}</h1>
  <p>{{body}}</p>
{{/each}}

Now, your app can automatically instantiate your controller classes and make the instances available on the router.

App.PostsController = Ember.ArrayController.extend({
  // ... your controller here ...
});

App.router = App.Router.create()
App.initialize(App.router);

// App.router now has the controller instances on it:
//   App.router.postsController (instance of App.PostsController)

If the controller is no longer available in the global scope, you may be wondering how you access it from your views. Instead of doing ad-hoc work on the global context, you will now set things up as needed as your router moves through different states.

Let's look at an example that connects views to their controllers using the router.

The first thing we will need is an application controller. The application controller is responsible for the high-level state of your app's UI.

App.ApplicationController = Ember.Controller.extend({
  view: null
});

Note that we're defining a subclass via extend() rather than creating an instance via create(). Again, the application's initialize method will instantiate it and make it available to the router for us as applicationController.

Now that we've got our controller, we'll need to create a root Ember.ContainerView. This view manages the main view. As your application moves through the router, the router's states will control what the root view displays.

For example, when entering the posts.index state, it will set the application controller's view property to an instance of App.PostIndexView. If you transition to the posts.show state, it will replace the application controller's view property with an instance of App.PostView.

Here's what the code for the router looks like. Note the setup of the main ContainerView inside the root state's setupControllers event:

var router = Ember.Router.create({
  root: Ember.State.extend({
    setupControllers: function(router) {
      var applicationController = router.get('applicationController'),
          rootView;
          
      rootView = Ember.ContainerView.create({
        controller: applicationController,
        currentViewBinding: 'controller.view'
      });
      
      rootView.appendTo('#content');
    },

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

      setupControllers: function(router) {
        var postsController = router.get('postsController');
        postsController.set('content', Post.find());
      },

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

        setupControllers: function(router) {
          var postsController = router.get('postsController'),
              appController = router.get('applicationController');

          // make an App.PostIndexView the current "main" view
          appController.set('view', App.PostIndexView.create({
            controller: postsController
          }));
        }
      }),

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

        setupControllers: function(router, post) {
          var postController = router.get('postController'),
              appController = router.get('appController');

          // Make an App.PostView the current "main" view
          appController.set('view', App.PostView.create({
            controller: postController
          }));
        }
      })
    })  
  })
});

Lastly, we'll make one small change to the Handlebars template. Instead of relying on the global scope, we'll just point to the controller that was set on the App.PostIndexView instance in the setupControllers event.

{{#each controller}}
  <h1>{{title}}</h1>
  <p>{{body}}</p>
{{/each}}

Nested Views

This is a great solution for when you only have one view to show at a time, but what if you want to display multiple views at once? For example, let's imagine that we want to toggle between displaying trackbacks and comments for a given blog post.

This is what the states might look like:

show: Ember.State.extend({
  router: '/:post_id',

  setupControllers: function(router, post) {
    var applicationController = router.get('applicationController'),
        postController = router.get('postController');

    postController.set('content', post);

    applicationController.set('view', App.PostView.create({
      controller: postController
    }));
  },

  comments: Ember.State.extend(),

  trackbacks: Ember.State.extend()
})

What happens if we enter the comments state? We obviously cannot just change the view property of our applicationController--that would replace the post body with the comments. We just want to change a subset of the view.

To understand what to do, let's first look at the template for App.PostView. It's similar to the template we used above, but we removed the {{each}} helper and added an Ember.ContainerView:

<h1>{{controller.content.title}}</h1>
<p>{{controller.content.body}}</p>
{{view Ember.ContainerView currentViewBinding="controller.view"}}

Now we have a ContainerView that will display the contents of its controller's view property. Remember that controller here is not the applicationController--it's the postController that we set up in the setupControllers event.

The next step is to implement the setupControllers event for the comments and trackbacks state. Remember that the role of a controller is to store the information needed for a view to render a model. In this case, we provide the instance of the model itself. But we also give the controller access to a new view instance (either App.CommentsView or App.TrackbacksView, depending on the state), because it is part of the information necessary for the PostView to render the model correctly.

show: Ember.State.extend({
  router: '/:post_id',

  setupControllers: function(router, post) {
    var applicationController = router.get('applicationController'),
        postController = router.get('postController');

    postController.set('content', post);

    applicationController.set('view', App.PostView.create({
      controller: postController
    }));
  },

  comments: Ember.State.extend({
    setupControllers: function(router) {
      var postController = router.get('postController');

      postController.set('view', App.CommentsView.create());
    }
  }),

  trackbacks: Ember.State.extend({
    setupControllers: function(router) {
      var postController = router.get('postController');

      postController.set('view', App.TrackbacksView.create());
    })
  }
})

You can use this approach to create nested views that map onto your router's hierarchy.

The parent template specifies where to insert its child views (using a ContainerView), and the router specifies which particular view should be inserted.

@pjmorse
Copy link

pjmorse commented Jun 7, 2012

@GauravShetty1016 @jbrown thanks, both of you. Looks like I'd better pull up to ember-latest.

@zdfs
Copy link

zdfs commented Jun 11, 2012

@jbrown's gist doesn't seem to work with 0.9.8.1, and being new to Ember.js, I'm not sure where to go for more information about this.

@zdfs
Copy link

zdfs commented Jun 11, 2012

@pjmorse
Copy link

pjmorse commented Jun 11, 2012

It works with the version of ember-latest currently at https://github.com/downloads/emberjs/ember.js/ember-latest.js (as in, as of when I posted this comment) but not with the ember.js I built from git a few days after that was uploaded, so it's clear that this is a moving target (or "very unstable API") at the moment. I'm using it because I don't see a better option, but I would recommend either staying with a fixed version or grabbing a commit which works and "freezing" your project to that. Unless you have time to refactor your app every few days.

@zdfs
Copy link

zdfs commented Jun 11, 2012

@pjmorse - this is just for a prototype, so the link you posted will work fine. Thank you.

@davidpett
Copy link

@jbrown, in your fiddle (http://jsfiddle.net/justinbrown/C7LrM/10/), why are you not using the leaf states that @wycats uses in his example? also, could you default to a substate of profile using redirectsTo: 'photos'? doesn't seem to work if i add it to your fiddle: http://jsfiddle.net/davidpett/8jpv8/

@ncri
Copy link

ncri commented Jun 17, 2012

Does this support translated routes for internationalized apps?

E.g. can the route string be somehow set dynamically from locale files? Also it would be cool when using Ember with Rails routing would not have to be specified twice...

Well, I assume in general with Rails one would simply specify very basic routes from within Rails and the rest in Ember? So there wouldn't be much duplication? Wonder if locale files from Rails could be used to lookup route translations. As a more general question: does Ember has any support for I18n already?

@0xdevalias
Copy link

Updated the example to work against ember-latest: http://jsfiddle.net/C7LrM/59/

@mediastuttgart
Copy link

now this works with embers latest http://jsfiddle.net/C7LrM/86/

is there a statement to what we should rely on when developing or should we still use stateManager until release/stable?

@jrabary
Copy link

jrabary commented Jun 25, 2012

What is the expected behavior of Ember.Route.transitionTo ? In this jsfiddle http://jsfiddle.net/TnuEn/17/ when one click on viewProfile, the state remain in root.profile. I expect it to be root.profile.index and then transits directly to root.profile.posts . If I enter the url directly the transition is correct. Is it a bug ? Is it related to the new navigateAway callback ?

Copy link

ghost commented Jul 14, 2012

Hey, I just updated to the version from the jsfiddle. I've been using the Router, but getting some weird behaviour so I hoped updating might sort it out, unfortunately I'm now getting the following Warning and Errors:

WARNING: Computed properties will soon be cacheable by default. To enable this in your app, set ENV.CP_DEFAULT_CACHEABLE = true. vendor.js:54720
Uncaught TypeError: Object function () { return initMixin(this, arguments); } has no method 'finishPartial' vendor.js:42905
Uncaught Error: Cannot find module "app"

Any clue what might be going here?

@krisselden
Copy link

@Mtadhg what browser + version are you using?

Copy link

ghost commented Jul 14, 2012

Hey Kselden, I'm on Chrome.

@krisselden
Copy link

There is a V8 bug that made it into Chrome's dev channel that will seriously mess with Ember.

(obj[key] = fn) === obj instead of (obj[key] = fn) === fn

http://code.google.com/p/v8/issues/detail?id=2226

@dagda1
Copy link

dagda1 commented Jul 15, 2012

am I right in saying we need a controller/view pair e.g. HomeController/HomeView for every outlet we want to connect?

Copy link

ghost commented Jul 15, 2012

@kselden I'm not sure I follow, that seems to affect Chrome Canary?! Are you saying this could be the cause of my problem?

@krisselden
Copy link

It made it into the dev channel and yes it looks like it.

@polyclick
Copy link

Are there any working routing examples for one of the latest emberjs builds? All jsfddles in this topic are broken.

@jobe451
Copy link

jobe451 commented May 17, 2013

It would be nice to know an example how to put queries to the URL, something like
/cars/model=Prius&yearStart=2006&yearEnd=2008

I am currently experimenting with RC3 and ember-data, I got such a case half way working, however I fail when I try to open such a route by "transitionToRoute". I got such a case working by updating manually the URL. The working part I described here: http://stackoverflow.com/questions/16506634/ember-route-with-dynamic-filter-search-criterias (accepted answer)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment