The definitive solution for history support on Ext JS

The definitive solution for history support on Ext JS

Have you ever used a single-page application where accidentally hitting the backspace key would “shoot you out” of there? Or when you are used to click back/forward on your browser but now you can’t because the app doesn’t support history? Believe me, it’s annoying.

History support allows your users to better navigate on your app. It also unleashes the power of URLs by enabling deep-linking. Transitioning from page to page is as simple as changing the URL.

It might sound like something really hard to implement, but it’s not. It’s very simple and you are only a few steps away from making your app fully history enabled! Let’s dig into it.

Basic History Support

Ext JS introduces history support with the Ext.util.History class. It solves cross-browser issues with the location.hash API and provides a solid foundation for your app. All you need to know can be summarized in 3 steps:

  1. Call Ext.util.History.init() to initialize it. You can do this on your app.js entry file
  2. Listen for changes Ext.util.History.on(‘change’, yourFunctionHere)
  3. Redirect your users to new views using Ext.util.History.add(“your-token-here”);

Now, Ext.util.History is great but there’s a lot of blanks to be filled. Check this example:

// 1. init
Ext.History.init();    

// 2. Listen for changes
Ext.History.on('change', function(token) {
    var match;
    
    //How to parse and dispatch an action?
    //Nested if/else logic?
    
    if (token === 'users/')
        renderUserList();
    }
    else if (match = token.match(/users\/(\d)/)) {
        renderUserForm(match[1]);
    }
    
    // not very scalable...
});

// 3. Redirect users with .add() method
function onTabChange(newTab) {
    Ext.History.add(newTab.href);
}

Once the hash change occurs, what do you do? Do you parse the token using a big switch/case? And if the token contains data, like a record ID or additional parameters, how do you parse then? We need additional functionality to complement this solution.

Advanced Ext JS History support

After using Ext.util.History for a while I figured it would not be able to solve my application history problem in the best way. It does provides a good foundation, but only when combined with a powerful routing system and fully integrated with Ext MVC is when we reach its true potential. Based on this I created Ext.ux.Router.

routes: {
    '/': 'home#index',
    'users': 'users#list',
    'users/:id/edit': 'users#edit'
}

Being able to decode the hash token is key for taking advantage of history support. Just like server-side frameworks that are able to parse GET requests, Ext.ux.Router can parse your “hash based URL”.

http://app.website.com/#user/3/contacts?sort=name

In additional to decode the URL we need some action to occur when it changes. The final step for the Router will be to match the proper route and dispatch a controller action.

How to implement

First step after downloading the JavaScript is requiring the class:

Ext.application({
    
    requires: [
        'Ext.ux.Router'
    ],
    
    ...

Second step is to define your routes. There’s a couple ways you can define routes, using regex, etc. The more traditional way is like the one below, where you have routes on the left matching controllers#action on the right.

Ext.application({
    ...
    routes: {
        '/'             : 'home#index',
        'settings'      : 'settings#index',
        'users'         : 'users#list',
        'users/:id/edit': 'users#edit'
    },
    ...

The last step is simply creating the controller actions:

Ext.define('SinglePage.controller.Users', {
    extend: 'Ext.app.Controller',

    edit: function(params) {
        var userId = parseInt(params.id, 10),
            usersEdit = this.render('usersedit')
        
        //TODO, load user
    },
    ...

With this routing engine sitting on top of Ext.util.History, we can develop a scalable solution for app navigation. When the URL hash changes, the routing system will figure out how to decode it, and all you need to do is develop a controller action. It will receive all params ready for consumption.

Decoupling Code

Personally what I like the most is the fact that it leads to code decoupling. Very often we something like the example below, where we have strong dependencies between views.

/**
 * User Form depends on User List. If there wasn't a previous list 
 * interaction, this code will break. We'll also have problems in the 
 * future if we develop more ways to get into the User Form, making 
 * this solution poorly scalable
 */
onUserFormRender: function(userForm) {
    var selectedUser = this.getUserList().getSelectionModel().getSelection()[0];
    
    userForm.getForm().load({
        url: 'users/' + selectedUser.getId()
    });
},

Now, let’s rewrite this for history support with Ext.ux.Router. A grid selection means redirecting the URL to “#users/3”. After that, the Router engine will solve the URL and the form action will get activated. The action will then render the form and fetch the data from the server with ID 3.

/**
 * When a record is selected, redirect the user. Enables
 * history support.
 */
onUserListSelect: function(userList, record) {
    Ext.ux.Router.redirect('users/' + record.getId());
},

/**
 * Action for route "users/:id". It renders the user form.
 * It doesn't matter how we got here. Only matters that we
 * have an user id and we will display it. Very scalable solution.
 */
actionUsersForm: function(params) {
    var tabPanel    = this.getTabPanel(),
        usersForm   = usersForm.child('usersform');
    
    if (!usersForm) {
        usersForm = tabPanel.add({
            xtype: 'usersform' 
        });
    }
    
    usersForm.getForm().load({
        url: 'users/' + params.id
    })
},

In this situation, all the form view knows is that a user id will be provided, and this will be everything it needs to render itself properly. Isolation :) If I send you an URL saying “check this record /users/3” you would have direct access to the view, without requiring a previous grid interaction.

Router Events

Inside the Router execution flow there are 3 events you can take advantage of: routemissed, beforedispatch and dispatch.

With routemissed event you can handle “local 404 errors”. Every time a route doesn’t match, the routemissed is triggered. Can be useful to show messages or anything.

The other two events, beforedispatch and dispatch, can be used to customize the router execution flow. You can potentially add validations to beforedispatch and prevent the route matching by returning false. You can also hook up rendering logic to the dispatch method.

/* 
 * Ext.ux.Router provides some events for better controlling
 * dispatch flow
 */
Ext.ux.Router.on({
    
    routemissed: function(token) {
        Ext.Msg.show({
            title:'Error 404',
            msg: 'Route not found: ' + token,
            buttons: Ext.Msg.OK,
            icon: Ext.Msg.ERROR
        });
    },
    
    beforedispatch: function(token, match, params) {
        Ext.log('beforedispatch ' + token);
    },
    
    /**
     * For this example I'm using the dispatch event to render the view
     * based on the token. Each route points to a controller and action. 
     * Here I'm using these 2 information to get the view and render.
     * Example:
     * 
     *  users/:id/edit -> controller Users, action edit ->
     *  renders user.Edit view.
     */
    dispatch: function(token, match, params, controller) {
        var view, viewClass, action,
            viewport    = Ext.getCmp('viewport'),
            target      = viewport.down('#viewport-target'),
            navToolbar  = viewport.down('#main-nav-toolbar');

        // adjust controller and action names    
        action      = Ext.String.capitalize(match.action);
        controller  = match.controller.charAt(0).toLowerCase() + match.controller.substr(1);

        // try to get the view by controller + action names
        viewClass   = Ext.ClassManager.get('SinglePage.view.' + controller + '.' + action);

        if (viewClass) {

            // create view
            view = Ext.create(viewClass, {
                border: false
            });

            // clear target and add new view
            target.removeAll();
            target.add(view);

            // adjust top toolbar
            if (navToolbar.child('#' + controller)) {
                navToolbar.child('#' + controller).toggle(true);
            }
        }
    }
});

Try it!

Ext.ux.Router is open-source an available at Github. The project has about 2 years now and it’s on a pretty solid state. I’ve also just updated the /examples folder so you have more code to start with.

Enabling history support on your app is a huge thing for users and as you saw it’s also very simple to implement. I hope you have all the information for getting the ball rolling, but you can always reach me in case of questions. Enjoy!