On the knockout site, you can find some great examples to get started, even give it a try in jsFiddle. But since my solution is a bit different from their tutorials, I wanted to share it with you.
The actual problem at hand was a page on which I needed to link a scanned in document to a dossier. Most of the times the number of the dossier can be picked up from the scanned in document, but this is not always the case. In case where the number of the dossier can't be determined from the scan, we want our users to go look for the dossier and link the dossier manually to the scanned in document.
The page to do this more or less looks like this (I rebuild the solution without most of the formatting we have in the initial application).
The initial setup for this contains a ScanController that gives you this Scan Detail page.
public class ScanController : Controller
{
public ActionResult Detail(int id)
{
var scan = new Scan
{
Id = id,
File = "This is the file",
DossierId = 0,
DossierNumber = string.Empty
};
return View(new ScanViewModel(scan, new Dossier()));
}
}
This uses the Detail view, which consists of two divs for the accordion. The top div looks like this:
@using (Html.BeginForm("Link", "Scan"))
{
<div>
<fieldset>
<dl>
<h2>Scan info</h2>
@Html.Label("Receipt date")
@Html.TextBox("receiptdate", string.Empty, new { @class = "date" })
</dl>
<h2>Dossier info</h2>
<div>
@Html.Hidden("file", Model.Scan.File)
<div id="linkDiv" @(Model.Scan.DossierId == 0 ? "class=hidden" : "")>
<div>Dossier: <span id="dossierNumberText">@Model.Scan.DossierNumber</span></div>
@Html.Hidden("dossierNumber", Model.Scan.DossierNumber)
<input type="submit" value="Link Scan to this dossier"/>
</div>
<div id="message" @(Model.Scan.DossierId == 0 ? "": "class=hidden")>
There is no dossier to link to. You should search for one.
</div>
</div>
</fieldset>
</div>
}
The bottom div looks like this:
<div id="search">
@using(Html.BeginForm("Search", "Dossier", FormMethod.Post, new { @id = "dossierForm" }))
{
<fieldset>
<dl>
@Html.LabelFor(m => m.Dossier.DossierNumber)
@Html.TextBoxFor(m => m.Dossier.DossierNumber)
</dl>
<dl>
@Html.LabelFor(m => m.Dossier.OwnerLastName)
@Html.TextBoxFor(m => m.Dossier.OwnerLastName)
</dl>
<dl>
@Html.LabelFor(m => m.Dossier.OwnerFirstName)
@Html.TextBoxFor(m => m.Dossier.OwnerFirstName)
</dl>
<dl>
<input type="submit" value="Search"/>
</dl>
</fieldset>
}
</div>
<div id="searchResult" class="hidden">
<table id="resultTable">
<thead>
<th>DossierNumber</th>
<th>OwnerLastName</th>
<th>OwnerFirstName</th>
<th>Detail</th>
<th>Link</th>
</thead>
<tbody>
</tbody>
</table>
</div>
It's this bottom div that we are most interested in. The top form (Search Dossier) will be used to perform an Ajax search. The result of this search will have to be shown in the table of the bottom searchResult div.
For this search I already added a Dossier controller with a Search action:
public class DossierController : Controller
{
public ActionResult Search(DossierSearchViewModel searchVm)
{
return Json(new
{
Success = true,
Message = "All's ok",
Data = new List<Dossier>
{
new Dossier
{
DossierNumber = "123abc",
OwnerFirstName = "John",
OwnerLastName = "Doe"
},
new Dossier
{
DossierNumber = "456def",
OwnerFirstName = "Jeff",
OwnerLastName = "Smith"
},
new Dossier
{
DossierNumber = "789ghi",
OwnerFirstName = "Peter",
OwnerLastName = "Jackson"
},
new Dossier
{
DossierNumber = "321jkl",
OwnerFirstName = "Carl",
OwnerLastName = "Turner"
},
}
});
}
}
If you click the Search button you will be get to see the Json result in the Dossier/Search page. This is not what we want, this form should perform an asynchronous Ajax post. That's not yet the case. For this I used the JQuery forms plugin. Which has a handy ajaxForm method you can add to your form. (you could also use the mvc ajax extensions for this, they should already be in your initial MVC setup).
<script type="text/javascript">
$(function () {
$("#dossierForm").ajaxForm({
success: render_dossier_grid
});
});
function render_dossier_grid(ctx) {
}
</script>
All of the knockout magic can now be added in the render_dossier_grid function. Before we can do this, make sure to add the knockout.js files to your solution. This can be easily done using nuget. Reference them in your _layout file, so you can use them.
First, let's create a viewmodel. This will be nothing more than a list of dossiers we get back from our dossier search. Since we want to be able to add and remove items from this list and at the same time have our table automatically show the new items, we will use a knockout observable array. To have the databindings applied to your view, you should call applyBindings for your viewmodel.
$(function () {
$("#dossierForm").ajaxForm({
success: render_dossier_grid
});
ko.applyBindings(viewModel);
});
function render_dossier_grid(ctx) {
}
var viewModel = {
dossiers: ko.observableArray([])
};
This viewModel can now be filled when we get the result of our Ajax call.
function render_dossier_grid(ctx) {
$("#searchResult").removeClass("hidden");
viewModel.dossiers.removeAll();
viewModel.dossiers(ctx.Data);
myAccordion.accordion("resize");
}
That's pretty easy. I just reset the dossiers observable array of the viewmodel and add the dossiers that come from the Ajax call. Last bit is having the JQuery accordion control perform a resize, just to get rid of any scroll bars.
Next step is actually binding this viewmodel to something. So, we need to specify in our view what data needs to go where. For this we will extend the table we have on our page.
<table id="resultTable">
<thead>
<th>DossierNumber</th>
<th>OwnerLastName</th>
<th>OwnerFirstName</th>
<th>Detail</th>
<th>Link</th>
</thead>
<tbody data-bind="foreach:dossiers">
<tr>
<td data-bind="text:DossierNumber"></td>
<td data-bind="text:OwnerLastName"></td>
<td data-bind="text:OwnerFirstName"></td>
<td>
<a data-bind="attr: {href: DetailLink}">Detail</a>
</td>
<td>
<a href="#" data-bind="click: $parent.useDossier">Use this file for linking</a>
</td>
</tr>
</tbody>
</table>
Adding the databindings is not that hard. I added a foreach binding, which will make a new table row for each dossier in the dossiers observable array. Each table row has its own binding for each td element. The first three are quite obvious. You can databind to the names of the properties of your domain (or MVC viewmodel) object.
For the second to last binding I added a databinding to the href attribute of an a tag. The DetailLink is a property on the Dossier domain object that gives you a link to Dossier/Detail/id.
The last one is a special binding to the click event of an a tag. It is bound to the useDossier function of the parent of the binding. The parent of our binding is the actual viewmodel. We still need to add this useDossier function:
var viewModel = {
dossiers: ko.observableArray([]),
useDossier: function (dossierVM) {
var dossierNumber = dossierVM.DossierNumber;
myAccordion.accordion({ active: 0 });
$("#dossierNumber").val(dossierNumber);
$("#dossierNumberText").text(dossierNumber);
$("#linkDiv").removeClass('hidden');
$("#message").addClass('hidden');
}
};
In this function I use the viewmodel. In this case this is the actual row/dossier of the table. I take the DossierNumber of the current dossier and use it to set the value of the dossierNumber and dossierNumberText fields in the top piece of the accordion. I also do some extra bits to update the UI accordingly.
That's it, not much to it. I really like this knockout framework. I did have some trouble to get started at first, but once you know your way around the viewmodel, it's pretty easy to use.
The full code can be found on github.