This post is a cross post from the Rally Engineering Blog: http://www.rallydev.com/community/engineering/mvc-client-side-javascript-deftjs
If you've worked with Javascript for any significant amount of time, you've likely run across tightly coupled UI/logic code that is difficult to test, debug, reuse, and modify. In Javascript land, this problem most often manifests itself as AJAX calls embedded inside of GUI components. While this pattern may work OK for small projects or projects using server-side generated html augmented with 'progressive enhancement' Javascript, it quickly turns into spaghetti code for large Javascript client applications. Our codebase usually does a good job of separating concerns, but occasionally we have logic sneaking into our UI code. Below is an example from a UI component that adds a new business artifact.
Ext.define('Rally.ui.AddNew', {
requires: ['Rally.ui.Button'],
extend: 'Ext.Container',
...
_onAddClicked: function() {
var record = Ext.create(this.models[this._getSelectedRecordType()]),
params = {fetch: true};
record.set('Name', this.down('#name').getValue());
if (this.fireEvent('beforecreate', this, record, params) !== false) {
Rally.ui.flair.FlairManager.showStatusFlair({
message: 'Creating ' + record.self.displayName + '...'
});
record.save({
requester: this,
callback: this._onAddOperationCompleted,
scope: this,
params: params
});
}
},
This component responds to a button click by creating an Ext record object and saving it by performing an AJAX call to Rally's REST API. This anti-pattern makes code reuse and testing very difficult. I can't reuse the UI with different logic, and I can't reuse the logic with a different UI. Testing the UI requires mocking out the business logic and AJAX calls, and testing the business logic and AJAX calls requires mocking out the UI. This component is clearly a candidate for being refactored using MVC to separate the logic and UI into reusable and testable units.
Enter DeftJs. DeftJs is an MVC library written specifically to work with Sencha's Ext class system. The small library makes it easy to refactor existing Ext components by adding three new features to the Ext environment:
Each of Deft's features is described in detail on their GitHub page, so I will focus on how to use the features together to architect an application. The diagram below represents an architecture that keeps separation of concerns clear, while utilizing Ext's core strengths, and being backwards compatible with existing components that are not MVC.
So how does this architecture affect the code of our AddNew component example? The controller is attached to the component by including an extra mixin and property in the component's definition, and all of the business logic is removed from the component:
Ext.define('Rally.ui.AddNew', {
requires: ['Rally.ui.Button'],
extend: 'Ext.Container',
mixins: [ 'Deft.mixin.Controllable' ], // enable controller
controller: 'Rally.deft.controller.AddNewController', // controller class to attach to view
... ui logic only ...
The controller class (in Coffeescript) wires the component to business logic encapsulated in Action objects:
Ext.define 'Rally.deft.controller.AddNewController',
extend: 'Deft.mvc.ViewController'
mixins: ['Deft.mixin.Injectable'] # enable dependency injection
inject: ['createRecordAction'] # inject createRecordAction attribute
control:
view:
# register handlers for view events here
submit: '_onSubmit' .
# this method called when the view component fires a 'submit' event
_onSubmit: (cmp, model, recordName) ->
view = @getView() # Deft automatically creates an accessor to the view
view.setLoading true
# injected properties (by default) are assigned to an instance.
# variable with the same name as the injected property
# invoke the injected action's method to create a new record
promise = @createRecordAction.createRecord
model: model
data:
Name: recordName
# a Promise object represents an operation that may or may not be asynchronous
promise.then
success: =>
# record creation was successful, reset view
view.reset()
promise.always =>
# remove loading mask and refocus view, whether the operation was successful or not
view.setLoading false
view.focus()
Notice that there are no AJAX calls in either the view or the controller. The AJAX logic lives in an Action class. Each Action returns a Deft.promise.Promise object, so that callers don't have to worry about whether or not the operation is asynchronous. Promise objects can also be chained or grouped together, which makes it easy to compose complex Actions from multiple simple Actions. The action code is listed below.
Ext.define 'Rally.deft.action.CreateRecord',
mixins: ['Deft.mixin.Injectable'] # enable dependency injection
inject: ['messageBus'] # inject messageBus property
createRecord: (options) ->
# the 'deferred' is the private part of the deferred/promise pair
# the deferred object should not be exposed to callers
deferred = Ext.create 'Deft.Deferred'
record = Ext.create options.model, options.data
# this Ext call initiates an AJAX operation
record.save
callback: (record, operation) =>;
@_onRecordSave(record, operation, options, deferred)
# return a promise object to the caller
deferred.getPromise()
_onRecordSave: (record, operation, options, deferred) ->
if operation.success
# call a method on an injected property.
# listeners can subscribe to the messageBus and be
# notified when an object is created
@messageBus.publish Rally.Message.objectCreate, record, this
# mark the deferred/promise pair as successful
deferred.resolve record
else
# mark the deferred/promise pair as failed
deferred.reject operation
Now that we have our logic code separated out, testing becomes much easier. The unit test below tests the functionality of a composed Action. All dependencies and AJAX calls are mocked out, so that the test is specific to the functionality of the class being tested. The syntax of the test assumes using the Jasmine testing framework and sinon.js mocking library.
describe 'CreateRecordOrOpenEditor', ->
beforeEach ->
# Reset injectors to make sure we have a clean slate before running test
Deft.Injector.reset()
# Mock dependencies needed for all tests.
Deft.Injector.configure
createRecordAction: value: {}
openCreateEditorAction: value: {}
messageBus: value:
publish: @stub
describe 'when createRecordAction fails with a validation error', ->
beforeEach ->
# mock failed AJAX operation
Deft.Injector.configure
createRecordAction:
createRecord: ->
deferred = Ext.create 'Deft.Deferred'
deferred.reject # immediately reject promise to simulate failed AJAX
deferred.getPromise()
openCreateEditorAction:
openCreateEditor: @stub
it 'should open editor', ->
createRecordOrOpenEditorAction = Ext.create 'Rally.deft.action.CreateRecordOrOpenEditor'
createRecordOrOpenEditorAction.createRecordOrOpenEditor({})
sinon.assert.calledOnce createRecordOrOpenEditorAction.openCreateEditorAction.openCreateEditor
it 'should publish displayNotification message', ->
createRecordOrOpenEditorAction = Ext.create 'Rally.deft.action.CreateRecordOrOpenEditor'
createRecordOrOpenEditorAction.createRecordOrOpenEditor({})
sinon.assert.calledOnce createRecordOrOpenEditorAction.messageBus.publish
sinon.assert.calledWith createRecordOrOpenEditorAction.messageBus.publish, Rally.Message.displayNotification
This experiment with DeftJs was successful in that we were able to refactor this component to be more maintainable, reusable, and testable, without requiring rewrites to other parts of the application. We're hoping to implement this architecture in our production code base to clean up some of the areas where concerns bleed together.