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 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:
- You must have a root state named
root
. - 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.
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.
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:
- 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.
- 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}}
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.
@GauravShetty1016 @jbrown thanks, both of you. Looks like I'd better pull up to ember-latest.