sencha-logoWow! Part 10 already! Looking back over the last 9 sessions, we’ve accomplished A TON. Starting from scratch, we’ve generated a brand new app, developed a nice little inventory management system, wrapped it up in role-based security, and even implemented a few shiny reports. We should feel pretty good about what we’ve accomplished, and so should our boss.

As is typical, though, pat-on-the-backs for a job well done generally come bundled with a list of 20 more things that need to be done…yesterday. In our case, management wants us to implement a role-based workflow system that will allow them to more granularly track how inventory products go from initial creation to a final “Approved” status.

This is no problem for us, however. By now, we’ve been around the block a few times with ExtJS 4.  Sure, the details vary from task to task, but we’ve found the sweet spot for attacking problems and solving them with code. All that’s left is to implement the skills that we are mastering on a daily basis.

NOTE: Code for this installment can be found on GitHub.

The Requirements

As mentioned, we need to implement a simple workflow process into our inventory management. While the workflow process will not alter the “how” of editing inventory records, it will add a layer of reporting and “sign off” for the various stages. WIth this new functionality, we want to accomplish at least the following:

  • Handle 5 statues: Approved, Rejected, Initiated, In-Audit, In-Review
  • Approval actions should be restricted by role:
    • Content Manager: Initiated -> In-Audit
    • Auditor: In-Audit -> In-Review
    • Admin: In-Review -> Approved, Approved -> Initiated, Rejected -> Initiated
  • If a role has permissions to an active status, they should be able to “Reject” it and send it back to the previous step (e.g., an Auditor rejects an inventory item in “In-Review” status, which sends it back to the Content Manager at “Initiated”)
  • Each workflow action should require a justification entered by the approver
  • Each inventory record should have a “history” of all workflow actions that have occurred for easy review

Easy enough, right? Let’s do it.

The Data Model

Let’s knock out the data model real quick. In our database, let’s create a new table to store the following data:

  • Car
  • LastStatus (the original status)
  • NextStatus (the new status)
  • Staff
  • Notes
  • Approved (whether the action was an approval [forward] or rejection [backward])

And here’s a stab at our Ext.data.Model in our ExtJS app:

/**
 * Model representing a Staff object
 */
Ext.define('CarTracker.model.Workflow', {
    extend: 'CarTracker.model.Base',    
    idProperty: 'WorkflowID',
    fields: [
        // id field
        {
            name: 'WorkflowID',
            type: 'int',
            useNull : true
        },
        // simple values
        {
            name: 'Notes',
            type: 'string'
        },
        {
            name: 'Approved',
            type: 'boolean'
        },
        // relational properties
        {
            name: 'LastStatus',
            type: 'auto'
        },
        {
            name: 'NextStatus',
            type: 'auto'
        },
        {
            name: 'Staff',
            type: 'auto'
        },
        // decorated properties
        {
            name: '_LastStatus',
            type: 'string',
            persist: false
        },
        {
            name: '_NextStatus',
            type: 'string',
            persist: false
        },
        {
            name: 'LastName',
            type: 'string',
            persist: false
        },
        {
            name: 'FirstName',
            type: 'string',
            persist: false
        }
    ] 
});

The Approach

Before we plunge head-first into code, let’s talk strategy. While there are a number of ways to accomplish what we want, I think the following will make our addition very unobtrusive to what we’ve already got in place:

  • Workflow Actions: Since our workflow actions need to occur on each inventory record, we can simply add the workflow actions to our already-existent context menu. That way, our workflow managers can quickly select actions without having to open the entire editing form.
  • Justification: When the workflow action is enacted, we can use the Ext.MessageBox.confirm() to pop-up a modal window with a multi-line text form for entering the justification text.
  • Workflow History: Since this will be a list of history records, the most obvious choice is, you guessed it, another grid! We are pro at making these by now, so there’s not much to talk about here.

Workflows.js

Since this workflow management represents something of a new “section” within our application, we should create a new controller for it. Let’s name it Workflows.js. In our current set of requirements, Workflows.js is going to be pretty thin. However, as is likely to happen, our application will inevitably grow, so segregating it now will future-proof our application against a need for a messy refactoring later on.

Let’s start simple and clean with the following in Workflows.js:

/**
 * Controller for all workflow-related management functionality
 */
Ext.define('CarTracker.controller.Workflows', {
    extend: 'CarTracker.controller.Base',
    ...

    /**
     * Handy method for checking whether the authenticated user has workflow permissions at a particular status
     * @param {Number} status
     */
    hasWorkflowPermission: function( status ) {
        var me = this,
            hasPermission = false,
            user = CarTracker.LoggedInUser;
        switch( status ) {
            case 4: // initiated
                hasPermission = user.inRole( 1 ) || user.inRole( 2 ) || user.inRole( 3 ) ? true : false;
                break;
            case 5: // in-audit
                hasPermission = user.inRole( 1 ) || user.inRole( 3 ) ? true : false;
                break;
            case 3: // in-review
            case 1: // approved
            case 2: // rejected
                hasPermission = user.inRole( 1 ) ? true : false;
                break;
        }
        return hasPermission;
    }
});

In our first pass, our Workflows.js has a single method: hasWorkflowPermission. This will allow us to pass in a status and determine whether or not the currently logged in user is in a role that has access to that particular status. In this example, permissions are cumulative, so an Auditor has access to all status beneath the Auditor permission level, the Admin has access to everything below the Admin level, and so on.

With this in place, we can now implement our Inventory grid context menu additions.

Cars.js Additions

In Cars.js, let’s locate the showContextMenu function and add our workflow options:

/**
 * Displays context menu 
 * @param {Ext.view.View} view
 * @param {Ext.data.Model} record 
 * @param {HTMLElement} item
 * @param {Number} index
 * @param {Ext.EventObject} e
 * @param {Object} eOpts
 */
showContextMenu: function( view, record, item, index, e, eOpts ) {
    var me = this;
    // stop event so browser's normal right-click action doesn't continue
    e.stopEvent();
    // set up dynamic array of items, based on permissions, role, workflow status, etc.
    var items = [];
    // add edit options; workflow state-restricted
    if( me.application.getWorkflowsController().hasWorkflowPermission( record.get( 'Status' ) ) ) {
        // setup workflow actions
        switch( record.get( '_Status' ) ) {
            case 'In-Review':
            case 'In-Audit':
                items.push({
                    text: 'Approve Workflow',
                    iconCls: 'icon_approve',
                    handler: function( item, e ) {
                        me.application.getWorkflowsController().fireEvent( 'approve', view, record, item, index, e, eOpts ); 
                    }
                });
                items.push({
                    text: 'Reject Workflow',
                    iconCls: 'icon_reject',
                    handler: function( item, e ) {
                        me.application.getWorkflowsController().fireEvent( 'reject', view, record, item, index, e, eOpts );
                    }
                });
                break;
            case 'Approved':
            case 'Rejected':
                items.push({
                    text: 'Restart Workflow',
                    iconCls: 'icon_refresh',
                    handler: function( item, e ) {
                        me.application.getWorkflowsController().fireEvent( 'restart', view, record, item, index, e, eOpts ); 
                    }
                });
                break;
            case 'Initiated':
                items.push({
                    text: 'Approve Workflow',
                    iconCls: 'icon_approve',
                    handler: function( item, e ) {
                        me.application.getWorkflowsController().fireEvent( 'approve', view, record, item, index, e, eOpts ); 
                    }
                });
                break;
        }
        items.push({
            text: 'Edit Car',
            iconCls: 'icon_edit',
            handler: function( item, e ) {
                me.edit( view, record, item, index, e, eOpts );
            }
        });
    }
    ...
    // add view option; no restrictions
    items.push({
        text: 'View Details',
        iconCls: 'icon_detail',
        handler: function( item, e ) {
            me.view( view, record, item, index, e, eOpts );
        }
    });
    // add delete option; admin role restriction
    if( CarTracker.LoggedInUser.inRole( 1 ) ) {
        items.push({
            text: 'Delete Car',
            iconCls: 'icon_delete',
            handler: function( item, e ) {
                me.remove( record );
            }
        });
    }
    // add menu
    item.contextMenu = new Ext.menu.Menu({
        items: items
    })
    // show menu relative to item which was right-clicked
    item.contextMenu.showBy( item );
},

A few things to note here.

First, you’ll notice that we’ve modified how the context menu is built. In previous versions of our app, our menu items were added directly to the items[] config. In this version, however, we are building an array of menu elements dynamically, based on certain conditions. This will allow us to avoid adding every single menu element; instead, we only add the ones that we need based on the user’s permissions AND the current workflow “state” of the Inventory record.

Next, you can see we’re using our new Workflows.js controller method hasWorkflowPermission to determine which workflow actions to add to the menu. We can access this controller from Cars.js in a number of ways, but a super-easy way is via the dynamically generated getter that ExtJS creates for us within the application: getWorkflowsController.

Finally, you’ll notice that for each workflow action we are firing some events (reject, restart, approve). However, we’re firing these events on our Workflows.js controller itself. This is great, because we can maintain separation between what occurs in the interface (e.g., the clicks on the Inventory grid records) and the business logic that needs to occur for workflows.

Now that this is in place, let’s add the listeners and methods in our Workflows.js controller:

/**
 * Controller for all workflow-related management functionality
 */
Ext.define('CarTracker.controller.Workflows', {
    extend: 'CarTracker.controller.Base',
    init: function() {
        this.listen({
            controller: {
                '#Workflows': {
                    approve: this.approveWorkflow,
                    reject: this.rejectWorkflow,
                    restart: this.restartWorkflow,
                    ...
                }
            },
            component: {},
            global: {},
            store: {},
            proxy: {} 
        });
    },
    /**
     * Submits an "Approve" workflow action
     * @param {Ext.view.View} view
     * @param {Ext.data.Record} record The record that belongs to the item
     * @param {HTMLElemen} item The item's element
     * @param {Number} index The item's index
     * @param {Ext.EventObject} e The raw event object
     * @param {Object} eOpts The options object passed to {@link Ext.util.Observable.addListener}
     */
    approveWorkflow: function( view, record, item, index, e, eOpts ) {
        var me = this;
        me.handleWorkflowAction( 'Approve', record );
    },
    /**
     * Submits a "Reject" workflow action
     * @param {Ext.view.View} view
     * @param {Ext.data.Record} record The record that belongs to the item
     * @param {HTMLElemen} item The item's element
     * @param {Number} index The item's index
     * @param {Ext.EventObject} e The raw event object
     * @param {Object} eOpts The options object passed to {@link Ext.util.Observable.addListener}
     */
    rejectWorkflow: function( view, record, item, index, e, eOpts ) {
        var me = this;
        me.handleWorkflowAction( 'Reject', record );
    },
    /**
     * Submits a "Restart" workflow action
     * @param {Ext.view.View} view
     * @param {Ext.data.Record} record The record that belongs to the item
     * @param {HTMLElemen} item The item's element
     * @param {Number} index The item's index
     * @param {Ext.EventObject} e The raw event object
     * @param {Object} eOpts The options object passed to {@link Ext.util.Observable.addListener}
     */
    restartWorkflow: function( view, record, item, index, e, eOpts ) {
        var me = this;
        me.handleWorkflowAction( 'Restart', record );
    },
    /**
     * Common interface for submitting workflow action to the server
     * @param {String} action
     * @param {Ext.data.Record} record
     */
    handleWorkflowAction: function( action, record ) {
        var me = this,
            msg;
        switch( action ) {
            case 'Approve':
            case 'Reject':
                msg = 'To ' + action + ' this workflow step, please enter a justification below.';
                break;
            case 'Restart':
                msg = 'To ' + action + ' the workflow for this record, please enter a justification below.';
                break;
        }
        Ext.Msg.minWidth=300;
        Ext.Msg.show({
            title: 'Workflow Management', 
            msg: msg, 
            fn: function( buttonId, text, opt ){
                if( buttonId=='ok' ) {
                    // make sure a message was entered
                    if( Ext.isEmpty( text ) ) {
                        Ext.Msg.alert( 'Attention', 'Please enter a justification for your action', function(){
                            me.handleWorkflowAction( action, record );
                        });                    
                        return false;
                    }
                    // send Ajax request with workflow action
                    Ext.Ajax.request({
                        url: '/api/workflows/' + record.get( 'CarID' ) + '.json',
                        method: 'PUT',
                        params: {
                            Status: record.get( 'Status' ),
                            Action: action,
                            Staff: CarTracker.LoggedInUser.get( 'StaffID' ),
                            Notes: text
                        },
                        success: function( response, opts ) {
                            // get new status for car
                            var result = Ext.decode( response.responseText );
                            // set record value to update record in grid
                            record.set( 'Status', result.data.Status );
                            record.set( '_Status', result.data._Status );
                        }
                    });
                }
            }, 
            scope: this, 
            width: 350,
            multiline: true,
            buttons: Ext.MessageBox.OKCANCEL
        });
    }
    ...
});

There’s a lot going on here, but the most important bits are highlighted in bold.

First, we setup a couple listeners (approve, reject, restart) to match the events we fire in Cars.js when workflow actions are activated. Notice how we’ve placed these listeners in the controller section of our this.listen(). The Ext.app.domain.Controller (controller domain) allows us to listen to events that occur in any of our application’s registered controllers. While we could use a wildcard (“*”) for the selector, we’ve decided to be specific in this example, and used the id of our controller (remember, we may expand later on…easier to be specific now, than to have to refactor later).

Each of our listeners fire specific methods, which all in turn call the handleWorkflowAction method. This method does two main things:

  1. Prompts the user to justify the workflow action via Ext.MessageBox.show()
  2. Submits an AJAX request to save the workflow action (and ostensibly update the car’s Status)

Now you might be wondering why we do a vanilla Ext.Ajax.request(), instead of adding a record to a store, syncing it, and persisting the record in that way. We could certainly do that, but then we’d have to instantiate the store, add data to it, and handle all the aspects related to that. In this scenario, I found it simpler to just handle the creation of workflow records in a more ad-hoc manner.

NOTE: While the approach to saving a record is a bit different here, it hopefully illustrates the flexibility of ExtJS 4 in how you interact with the server. Whether you are proxying the server via a store, or simply interacting with Ext.Ajax, you have a variety of options available…the hardest part is just deciding the best one to use by scenario.

And once the result is returned in our Ajax request’s success, we simply update the Inventory record with new status. Cake.

Workflow History

The last piece we need to implement is the ability to view the workflow history of individual Inventory records. Since we’ve already being hacking on our Inventory context menu, let’s add one more option there and call it “View Workflow History”:

// add view workflow option; no restrictions
items.push({
    text: 'View Workflow History',
    iconCls: 'icon_workflow',
    handler: function( item, e ) {
        me.application.getWorkflowsController().fireEvent( 'view', view, record, item, index, e, eOpts );
    }
});

As with the workflow actions, we can simply specify another event that we want our Workflows.js controller to handle. In this case, we’ll call the new event “view”.

Of course, now we need to add the listener to our Workflows controller:

this.listen({
    controller: {
        '#Workflows': {
            ...
            view: this.showHistory
        }
    },
    component: {
        'grid[xtype=workflow.list]': {
            beforerender: this.loadWorkflowHistory
        }
    }
});

So now to wrap this up, we simply need to fill out our two methods:

  • showHistory: Will display a window with a grid full of history records for the selected Inventory record
  • loadWorkflowHistory: Will do a custom load of our Workflows.js store
/**
 * Displays all workflow history for the selected Car
 * @param {Ext.view.View} view
 * @param {Ext.data.Record} record The record that belongs to the item
 * @param {HTMLElemen} item The item's element
 * @param {Number} index The item's index
 * @param {Ext.EventObject} e The raw event object
 * @param {Object} eOpts The options object passed to {@link Ext.util.Observable.addListener}
 */
showHistory: function( view, record, item, index, e, eOpts ) {
    var me = this,
        win;
    // create ad-hoc window
    Ext.create('Ext.window.Window', {
        title: 'Workflow History',
        iconCls: 'icon_workflow',
        width: 600,
        maxHeight: 600,
        autoScroll: true,
        modal: true,
        y: 100,
        items: [
            {
                xtype: 'workflow.list',
                CarID: record.get( 'CarID' )
            }
        ]
    }).show();
}

In this case, we’ve decided to ditch creating a custom “window” class for containing our grid. Since we don’t have any particular actions associated with workflow history viewing (e.g. not toolbar buttons, search forms, etc.), we didn’t feel the need to create a whole class just to display our grid.

The important thing to notice here, however, is that when we instantiate our workflow history grid, we are setting a special config called CarID. This will allow us to easily retrieve the ID of the inventory record when we load the workflow history; so instead of querying the database of ALL history records, we can retrieve only those for the selected inventory record.

And finally, per our listener, we will call loadWorkflowHistory before our workflow history grid is rendered:

/**
 * Loads workflow history store
 * @param {Ext.grid.Panel}
 * @param {Object} eOpts The options object passed to {@link Ext.util.Observable.addListener}
 */
loadWorkflowHistory: function( grid, eOpts ) {
    var me = this,
        store = grid.getStore();

    store.getProxy().url = '/api/workflows/' + grid.CarID;
    store.load();
}

This is nothing different than what we’ve done before. The only change is that we manipulate the store’s proxy url before loading, allowing us to include the CarID config that we set when instantiating our store’s grid (see above).

Awesome. Put all together, we should get something that looks like this:

WorkflowHistory

Bonus: RowExpander

In the screenshot above, you may have noticed the “+” ticks next to each row in the workflow history grid. These are created by a special plugin (Ext.grid.plugin.RowExpander) that allows an additional row to be added after each row. The additional row supports a special renderer into which any arbitrary HTML can be inserted. The config for this is incredibly simple. Check out this snippet from our workflow history grid:

/**
 * Grid for displaying Workflow details for the selected Car
 */
Ext.define('CarTracker.view.workflow.List', {
    extend: 'Ext.grid.Panel',
    alias: 'widget.workflow.list',
    requires: [
        'Ext.grid.column.Date',
        'Ext.grid.plugin.RowExpander'
    ],
    ...
    plugins: [
        {
            ptype: 'rowexpander',
            rowBodyTpl : new Ext.XTemplate(
                'Notes: {Notes}'
            )
        }
    ],
    initComponent: function() {
        ...
    }
});

All we have to do is to add the RowExpander via the grid’s plugins[] config. Within the RowExpander configuration, we use the rowBodyTpl to create a new Ext.XTemplate which will render the new row’s content. The content which we place into the new row can be anything, but in this example, we simply want to render the Notes property from our model.

NOTE: The RowExpander is especially ideal for displaying model fields that may contain a large amount of information that does not lend itself to tabular display. By using an entire row, the RowExpander helps keep your grid nice and compact, but still provides the ability to see larger sections of information without disrupting the layout of your grid.

Wrapping Up

And there we have it…another section in the bag. By this point, we should be finding our legs with ExtJS 4, and our development speed should be rapidly increasing. Since we’re getting more and more comfortable with how all the pieces fit together, we can spend much more time making awesome stuff happen, instead of spinning our wheels figuring out how to make it work. This is when ExtJS 4 really becomes a joy to work with.