Twitter Bootstrap Modals and Knockout.js

Displaying modal dialog boxes directly from your Knockout view models turns your application into unmaintainable jQuery spaghetti. Your once clean view model, is now fighting with the DOM and dragging you into callback hell.

Imagine instead maintaining a clear separation of concerns between your view models and your UI. Views models only need to collaborate with other view models, never with the DOM.

Keep your code clean and elegant - just like your application!

What you'll learn

This post will teach you how to use Twitter Bootstrap modals in a Knockout.js application, while keeping your code clean and simple.

You'll learn how to use Knockout's built-in function, ko.renderTemplate, to generate a modal dialog box UI, on-demand, from a template.

This post will also show how jQuery's Deferred object can neatly manage the asynchronous nature of displaying a modal dialog and waiting for its result.

View model flow

In this post, we'll be discussing the interaction between an application's root view model, which is data-bound to the page, and a separate view model, data-bound to a modal dialog.

The flow of interaction when displaying a modal is shown below.

Modal interaction
The viewmodel/view interaction of a modal dialog

Twitter Bootstrap Modal

Twitter Bootstrap provides a very nice looking modal component. However, using the simple $.fn.modal jQuery plugin directly from a Knockout view model can result in DOM code creeping in where it doesn't belong.

The Bootstrap modal plugin requires there to be an element in the page which it will display as a modal dialog. This element will look something like the following:

Modal Dialog Screenshot
Screenshot of the modal dialog

This UI is created by the following HTML.

<div class="modal hide fade">
    <div class="modal-header">
        <button type="button" class="close" aria-hidden="true" data-bind="click: cancel">&times;</button>
        <h3>Add Note</h3>
    </div>
    <div class="modal-body">
        <form action="#" data-bind="submit: add">
            <div class="control-group">
                <label class="control-label">New note:</label>
                <div class="controls">
                    <textarea data-bind="value: text"></textarea>
                </div>
            </div>
            <div class="control-group">
                <div class="controls">
                    <label class="checkbox"><input type="checkbox" data-bind="checked: important" />Important</label>
                </div>
            </div>
        </form>
    </div>
    <div class="modal-footer">
        <a href="#" class="btn btn-primary" data-bind="click: add">Add Note</a>
        <a href="#" class="btn" data-bind="click: cancel">Cancel</a>
    </div>
</div>

We need a way to dynamically generate this HTML at runtime and also data bind it to a separate view model. Luckily Knockout provides ko.renderTemplate to do exactly this.

Rendering modal templates

ko.renderTemplate accepts five arguments:

templateName The ID of the template to render
viewModel The view model to data bind to the template
options Additional options passed to the rendering engine. We'll be providing an afterRender callback here.
target Where to render the template, such as a <div> element.
renderMode When this is "replaceNode" the target element is replaced with the rendered output.

The rendered elements do not necessarily appear in the DOM immediately. We have to provide an afterRender callback function via the options argument.

ko.renderTemplate(
    "mytemplate",
    viewModel,
    {
        afterRender: function(renderedElement) {
            console.log("rendered!");
        }
    },
    target,
    "replaceNode"
);

Let's wrap up this complexity in helper function that returns a jQuery Deferred. This will make it easy to chain together rendering of templates with code that uses the output.

var createModalElement = function(templateName, viewModel) {
    var temporaryDiv = addHiddenDivToBody();
    var deferredElement = $.Deferred();
    ko.renderTemplate(
        templateName,
        viewModel,
        // We need to know when the template has been rendered,
        // so we can get the resulting DOM element.
        // The resolve function receives the element.
        {
            afterRender: function (nodes) {
                // Ignore any #text nodes before and after the modal element.
                var elements = nodes.filter(function(node) {
                     return node.nodeType === 1; // Element
                });
                deferredElement.resolve(elements[0]);
            }
        },
        // The temporary div will get replaced by the rendered template output.
        temporaryDiv,
        "replaceNode"
    );
    // Return the deferred DOM element so callers can wait until it's ready for use.
    return deferredElement;
};

var addHiddenDivToBody = function() {
    var div = document.createElement("div");
    div.style.display = "none";
    document.body.appendChild(div);
    return div;
};

The view models

An application view model will be responsible for initiating the display of a modal. It will create a separate view model object for the modal and call a helper function to show the modal.

Here's a simple application view model that shows a modal:

var AppViewModel = function() {
    this.notes = ko.observableArray();
};

AppViewModel.prototype.addNote = function() {
    showModal({
        viewModel: new AddNoteViewModel(),
        context: this // Set context so we don't need to bind the callback function
    }).then(this._addNoteToNotes);
};

AppViewModel.prototype._addNoteToNotes = function(newNote) {
    this.notes.push(newNote);
};

This view model provides a method that can be bound to the click event of a button or link. It shows the modal dialog UI, which is data-bound to the given view model. Then it waits for the modal to be closed with some result data.

We'll dive into the showModal function soon. For now, note that it is returning deferred object that is resolved when the modal has been closed. The modal is able to pass back a result, which in this case the application view model is adding to its notes array.

Here's the AddNoteViewModel, which will be data bound to the modal UI:

var AddNoteViewModel = function() {
    this.text = ko.observable();
    this.important = ko.observable();
};
    
// The name of the template to render
AddNoteViewModel.prototype.template = "AddNote";

AddNoteViewModel.prototype.add = function () {
    var newNote = {
        text: this.text(),
        important: this.important()
    };
    // Close the modal, passing the new note object as the result data.
    this.modal.close(newNote);
};

AddNoteViewModel.prototype.cancel = function () {
    // Close the modal without passing any result data.
    this.modal.close();
};

The AddNoteViewModel provides the name of the template to render. This will be used by the showModal function.

The view model is also in control of when to close the modal. It assumes that a modal property has been attached to itself. This object contains a close method that is used to close the modal and optionally pass a result.

The showModal function

Here's the showModal function:

var showModal = function(options) {
    if (typeof options === "undefined") throw new Error("An options argument is required.");
    if (typeof options.viewModel !== "object") throw new Error("options.viewModel is required.");

    var viewModel = options.viewModel;
    var template = options.template || viewModel.template;
    var context = options.context;
        
    if (!template) throw new Error("options.template or options.viewModel.template is required.");
        
    return createModalElement(template, viewModel)
        .pipe($) // jQueryify the DOM element
        .pipe(function($ui) {
            var deferredModalResult = $.Deferred();
            addModalHelperToViewModel(viewModel, deferredModalResult, context);
            showTwitterBootstrapModal($ui);
            whenModalResultCompleteThenHideUI(deferredModalResult, $ui);
            whenUIHiddenThenRemoveUI($ui);
            return deferredModalResult;
        });
};

After some standard pre-condition validation, the function starts the process of showing the modal. The pipe method provides a nice way to chain together a set of deferred operations.

First, the modal element creation is started. Once Knockout has finished updating the DOM, the next operation in the pipeline runs.

The call to pipe($) is equivalent to:

pipe(function(modalElement) {
    return $(modalElement);
})

Because this returns a non-deferred value, the next stage of the pipeline executes immediately after.

The final stage of the pipline creates a new deferred object that will contain the result of the modal when it is closed. A helper object is attached to the view model so it is able to close the modal.

var addModalHelperToViewModel = function (viewModel, deferredModalResult, context) {
    // Provide a way for the viewModel to close the modal and pass back a result.
    viewModel.modal = {
        close: function (result) {
            if (typeof result !== "undefined") {
                deferredModalResult.resolveWith(context, [result]);
            } else {
                // When result is undefined, we don't want any `done` callbacks of
                // the deferred being called. So reject instead of resolve.
                deferredModalResult.rejectWith(context, []);
            }
        }
    };
};

The deferred object's resolveWith method is used to provide a useful value for this in any callback functions. The context value was provided to the showModal function via its options argument.

The signature of resolveWith is similar to Function.prototype.apply i.e. the arguments must be passed as an array.

Sometimes a user will need to cancel a modal. To handle this case, close can be called with an undefined result. The deferred modal result is then rejected, instead of resolved. This means that the "happy path" in the application view model doesn't need to check for a cancelled modal. Cancelling can be detected by providing a fail callback instead.

showModal({ viewModel: new ModalViewModel() })
    .done(function(result) {
        console.log("Modal closed with result: " + result);
    })
    .fail(function() {
        console.log("Modal cancelled");
    });

The remaining helper functions called by showModal are listed below:

var showTwitterBootstrapModal = function($ui) {
    // Display the modal UI using Twitter Bootstrap's modal plug-in.
    $ui.modal({
        // Clicking the backdrop, or pressing Escape, shouldn't automatically close the modal by default.
        // The view model should remain in control of when to close.
        backdrop: "static",
        keyboard: false
    });
};
    
var whenModalResultCompleteThenHideUI = function (deferredModalResult, $ui) {
    // When modal is closed (with or without a result)
    // Then always hide the UI.
    deferredModalResult.always(function () {
        $ui.modal("hide");
    });
};

var whenUIHiddenThenRemoveUI = function($ui) {
    // Hiding the modal can result in an animation.
    // The `hidden` event is raised after the animation finishes,
    // so this is the right time to remove the UI element.
    $ui.on("hidden", function() {
        // Call ko.cleanNode before removal to prevent memory leaks.
        $ui.each(function (index, element) { ko.cleanNode(element); });
        $ui.remove();
    });
};

When a modal is closed, it is removed from the DOM to free up memory. However, Twitter Bootstrap modal's hide method will animate the hiding. Instant removal would prevent this nice effect.

A two-step approach is used to remove the modal once the deferred result has been resolved or rejected. The Bootstrap modal "hide" method is called. This will raise the "hidden" event once the animation has finished. At that point we can remove the modal's DOM element.

Extensions and ideas

The use of ko.renderTemplate to dynamically generate UI can be generalised for other components. For example, Twitter Bootstrap Popovers.

Occasionally one modal will need to display another modal. There are two possible flows to consider:

  1. Close the first modal, then show the second.
  2. Stack the second modal on top of the first.

In the first case, the modal's deferred result object must be passed onto the second modal. The application view model shouldn't resume until the second modal has been closed.

The modal helper object assigned to view models could be extended to support this scenario. Here's a possible API:

FirstViewModel.prototype.showSecond = function() {
    this.modal.closeCurrentAndShowModal(new SecondViewModel());
    // The first modal is now removed from the DOM.
};

The second case is somewhat simpler. A separate deferred result object is created for the second modal. The first modal remains in the background, ready handle the result when the second modal is closed.

Again, the modal helper object could be enhanced to support this behavior:

FirstViewModel.prototype.showSecond = function() {
    this.modal.showNestedModal(new SecondViewModel());
    // The first modal is still loaded, but hidden until the second is closed.
};

To prevent Twitter Bootstrap adding multiple backdrop overlays to the page, the first modal could be hidden, but not removed, while the second modal is displayed.

Full sample code

To get the full sample code that accompanies this blog post, join my web development mailing list. I'll give you access to a private GitHub repository containing code samples, which you can copy and use in your own projects.

Thanks for reading

I hope you found this post useful. Let me know in the comments, or on twitter @andrewdavey. Feel free to copy, modify and use the code in your applications.

Would you like access to the sample code repository?

Subscribe to my web development mailing list.

No spam, just occasional web development tips, posts and sample code. Unsubscribe at any time.

Comments
blog comments powered by Disqus