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.
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:
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">×</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:
- Close the first modal, then show the second.
- 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.