App Modernization - Part #2: Migrate underscore.js templates and JQuery data loading
This article is the second part of the series:
- App Modernization - Part #1: Migrate Web Forms code islands to DotVVM
- App Modernization - Part #2: Migrate underscore.js templates and JQuery data loading
For this example, we will return to our fictional customer internal e-shop pages written in ASP.NET Web Forms. We will migrate an ASPX page that uses jquery and underscore to load and display data.
Legacy solution
This example needs a bit of explanation beforehand. There is a page Products.aspx
that displays a list of products. When the user clicks at the product row, a dialog with additional information opens. The data in the product table as well as data in the detail dialog are loaded using JQuery. The page which provides the data for the requests from Products.aspx
is also an ASPX page ProductsService.aspx
. The service page handles the request and displays JSON as the content of the page. The architecture of the legacy solution is as follows:
----------------- ------------------------
| Products.aspx | ------action:list---> | ProductsService.aspx |
----------------- <-------JSON--------- ------------------------
| |
| ----action:detail---> |
| <-------JSON--------- |
The files in our project structure we need to look at are these:
Model
Product.cs
Facades
ProductFacade.cs
Content
products.js
Pages
Products.aspx
Products.aspx.cs
ProductsService.aspx
ProductsService.aspx.cs
Products.aspx
:
<form id="ProductsForm" runat="server">
<script type="text/template" id="product-template">
<tr>
<td>
<input type="hidden" value="{{ product.Id }}"/>
{{ product.Code }}
</td>
<td>{{ product.Name }}</td>
<td>{{ product.Price }} EUR</td>
</tr>
</script>
<script type="text/template" id="product-dialog-template">
<div class="product-dialog-template">
<h3>{{ product.Name }}</h3>
<p>
<span>Code:</span> {{ product.Code }}
</p>
<p>
<span>Price:</span> {{ product.Price }} EUR
</p>
<p>
<span>Description:</span> {{ product.Description }}
</p>
</div>
</script>
<div id="product-dialog"></div>
<table id="products-table">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</form>
products.js
:
$(function () {
_.templateSettings = {
interpolate: /\{\{(.+?)\}\}/g
};
const productTableTemplate = _.template($('#product-template').html());
const dialogTemplate = _.template($('#product-dialog-template').html());
const $productDialog = $('#product-dialog');
$productDialog.dialog({
autoOpen: false,
modal: true,
buttons: {
"Close": function () {
$(this).dialog("close");
}
}
});
// Sample product data
$.get("/ProductsService.aspx", {},
function (products) {
_.each(products, function (product) {
$('#products-table tbody').append(productTableTemplate({ product: product }));
});
}.bind(this));
// Show the product dialog on row click
$('#products-table tbody').on('click', 'tr', function (sender, event) {
const id = new Number($(sender.currentTarget).find("input[type=hidden]").attr("value"));
$.get("/ProductsService.aspx", {action: "detail", id: id},
function (product) {
$productDialog.html(dialogTemplate({ product: product }));
$productDialog.dialog("open");
}.bind(this));
});
});
Fast and dirty
When we copy the content of the Products.aspx
into a new DotVVM page, one issue is obvious. The DotVVM parses the template code islands as DotVVM bindings.
One of the possible strategies is to make as few changes as possible. We could use {{resource: }}
binding to "escape" the templates. This solution will allow you to migrate the page very fast. In most cases we found out that the javascript on the page works correctly after this change, and the page was usable. There are certainly downsides with this solution. The solution is bit fragile, there is no advantage in maintainability and adding new features is just as bothersome as it was before. But if this is just some unimportant page, hardly used, that is holding you from switching to latest .NET, this solution might work for you.
Proper migration
If you want a full migration to DotVVM, it will be bit more involved process, but there are also good news. We will gain strong typing and we will be able to completely get rid of javascript for pages like. The first course of action is to refactor templates into DotVVM controls and replace JQuery UI dialog with quite easy DotVVM equivalent.
Plan of action is as follows:
- Create the
Products.dothtml
page without the templates - Migrate the
product-template
into the DotVVMRepeater
control - Migrate the dialog into the
ProductDialog
markup control - Migrate
ProductsService.aspx
toProductsUIService
for the on-demand loading of dialog content
Step 1: The page
Creating Products.dothtml
page is quite straight-forward. We don't need any of the templates, we will deal with them separately. So we just need to copy over the table, and later we will add a custom control for the dialog.
@viewModel DotVVM.Samples.Migrated.Pages.Products.ProductsViewModel, TestSamples
@masterPage Migrated/Pages/Site.dotmaster
<dot:Content ContentPlaceHolderID="MainContent">
<table id="products-table">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</dot:Content>
In the the viewmodel, we prepare a collection to hold the products, and later we will add the property to accommodate the dialog.
public class ProductsViewModel : SiteViewModel
{
public List<Product> Products { get; set; }
}
Step 2: The product table
It is time to take care of product-template
template. We could move the content of this template to a custom markup control. But since the table is simple, we may be able to get away with using it directly as a content of <dot:Repeater>
.
We look at the line 22
of the products.js
JavaScript file;
function (products) {
_.each(products, function (product) {
$('#products-table tbody').append(productTableTemplate({ product: product }));
});
}.bind(this);
We can see underscore.js
being used to iterate products
using each
and fill containing element with new elements created by evaluating productTableTemplate
underscore.js template on data provided by the data provided by the elements of the products
array.
Such construction can be directly equated to DotVVM <dot:Repeater>
control. The repeater iterates Products
we provide as DataSource
and uses provided ItemTemplate
to fill the table. The data context for the ItemTemplate
is the item of Products
list.
We can see that the code islands of the product-template
template look a lot like bindings, and this is precisely what we will do with them. We will turn them into value bindings. Since data context of the ItemTemplate
of a <dot:Repeater>
is the item of the Products
lists itself we don't need to specify value: product.Something
for the bindings we can just write value: Something
. Also we do not need the <input type="hidden" ... />
because all the data are in the viewmodel.
After migrating our product-template
to DotHTML and using the <dot:Repeater>
instead of the _.each(...)
our table in Products.dothtml
looks like this:
<table id="products-table">
<thead>
<tr>
<th>Code</th><th>Name</th><th>Price</th>
</tr>
</thead>
<dot:Repeater DataSource={value: Products} WrapperTagName="tbody">
<tr>
<td>
{{ value: Code }}
</td>
<td>{{ value: Name }}</td>
<td>{{ value: Price }} EUR</td>
</tr>
</dot:Repeater>
</table>
Notice that we use the WrapperTagName="tbody"
property to tell the repeater to use <tbody>
tag as a container for the rows.
Step 3: The dialog template
The original Products.aspx
page uses a dialog to display detailed information about each product. We find the content of the dialog in underscore.js template with id
of product-dialog-template
. The template is evaluated in JavaScript and the dialog is opened as we can see on line 34
and 35
of products.js
file.
$productDialog.html(dialogTemplate({ product: product }));
$productDialog.dialog("open");
Here, the raw HTML is being set into the div
that will contain the dialog itself. JQuery library function dialog
then builds the dialog and dialog overlay for us based on our content. Also note that the dialogTemplate
template is initialized above in the javascript projects.js
file:
const dialogTemplate = _.template($('#product-dialog-template').html());
We need to represent this functionality in in DotVVM. First we need to migrate the content of the product-dialog-template
template into DotVVM markup. The template code islands are quite straight forward we just replace them with value bindings.
The migrated content of the product-dialog-template
template:
<div class="product-dialog-template">
<h3>{{value: Name }}</h3>
<p>
<span>Code:</span> {{value: Code }}
</p>
<p>
<span>Price:</span> {{value: Price }} EUR
</p>
<p>
<span>Description:</span> {{value: Description }}
</p>
</div>
<dot:Button Click={staticCommand: IsVisible = false} Text="Close" />
For now, we just migrate the content we will deal with using it in the page later.
We added the "Close" button that we have seen in the dialog configuration on line 13
of the products.js
file:
buttons: {
"Close": function () {
$(this).dialog("close");
}
}
It's job is semantically the same. It just closes the dialog. The tricky part is that we need to look in the ASPX page for the template and look in the JavaScript for the dialog configuration.
Since the dialog has custom data to manage, best practice is to create ProductDialogViewModel.cs
viewmodel for the dialog. The markup can guide us here. We start with empty viewmodel for the control and create the properties based on the bindings in the markup (CTRL+.
and Create property in the viewmodel
if you have pro version of the DotVVM for VisualStudio). Plus, for showing and hiding the dialog we use IsVisible
property that we add to the dialog viewmodel.
ProductDialogViewModel.cs
public class ProductDialogViewModel
{
public string Name { get; set; }
public string Code { get; set; }
public decimal Price { get; set; }
public string Description { get; set; }
public bool IsVisible { get; set; }
}
With the dialog viewmodel ready, we can put a property for the dialog data into our main viewmodel ProductsViewModel.cs
.
public ProductDialogViewModel Dialog { get; set; } = new ProductDialogViewModel();
Next we need to add the functionality of a dialog. We have several options, and we will explore all of them.
- DotVVM Bootstrap
ModalDialog
control - DotVVM Business Pack
ModalDialog
control - Vanilla DotVVM custom dialog
Step 3.1: Dialog in Bootstrap for DotVVM
If you have a license for Bootstrap for DotVVM, you can use the bs:ModalDialog
control. We would simply put the migrated <div class="product-dialog-template">...<div/>
inside of the dialog ContentTemplate
. The buttons have a separate template so we put <dot:Button ... Text="Close" ... />
into the FooterTemplate
. We would wire the DotVVM properties DataContext
to the viewmodel property Dialog
, and then we would use IsDisplayed={value: IsVisible}
to control whether the dialog is shown or hidden.
The page Products.dothtml
with bs:ModalDialog
included should then look like this:
<dot:Content ContentPlaceHolderID="MainContent">
<bs:ModalDialog DataContext={value: Dialog} IsDisplayed="{value: IsVisible}">
<div class="product-dialog-template">
<h3>{{value: Name }}</h3>
<!-- ... The rest of the migrated content of the template from before -->
</div>
<dot:Button Click={staticCommand: IsVisible = false} Text="Close" />
</bs:ModalDialog>
<table id="products-table">
...
</table>
</dot:Content>
The Bootstrap for DotVVM provides all the necessary functionality of the dialog making our job a bit easier.
Step 3.2: Dialog in DotVVM Business Pack
With DotVVM Business Pack, it is a very similar story. We would put the migrated content of <div class="product-dialog-template">...<div/>
together with <dot:Button ... Text="Close" ... />
into the content of bp:ModalDialog
. We would wire DotVVM properties DataContext
to viewmodel property Dialog
, and then we would use IsDisplayed={value: IsVisible}
. This would save us from having a custom dialog.
We can put the bp:ModalDialog
control into the Products.dothtml
page and fill the HeaderTemplate
, ContentTemplate
, FooterTemplate
:
<dot:Content ContentPlaceHolderID="MainContent">
<bs:ModalDialog DataContext={value: Dialog} IsDisplayed="{value: IsVisible}">
<HeaderTemplate>
Product detail: {{value: Name}}
</HeaderTemplate>
<ContentTemplate>
<div class="product-dialog-template">
<h3>{{value: Name }}</h3>
<!-- ... The rest of migrated content -->
<!-- from the template from before -->
</div>
</ContentTemplate>
<FooterTemplate>
<dot:Button Click={staticCommand: IsVisible = false} Text="Close" />
</FooterTemplate>
</bs:ModalDialog>
<table id="products-table">
...
</table>
</dot:Content>
The dialog migration is done, DotVVM Business Pack provides the necessary dialog functionality.
Step 3.3: Dialog in vanilla DotVVM
Making dialog in vanilla DotVVM is not complicated at all. It just requires a few tricks we can explore.
To keep our markup nice and tidy, we create new ProductDialog.dotcontrol
control file with our already created ProductDialogViewModel.cs
viewmodel. So we start the file with @viewmodel
directive and copy the migrated content of the product-dialog-template
template into the file. With this we should get a nice start for our ProductDialog
markup control.
We must not forget to register the markup control in the DotvvmStartup
:
config.Markup.AddMarkupControl(
"cc",
"ProductDialog",
"Migrated/Pages/Products/Controls/ProductDialog.dotcontrol");
After we have prepared the basic migrated content for ProductDialog
, we need to add the functionality of a dialog.
All that said, creating dialog in vanilla DotVVM is not hard at all. We modify ProjectDialog.dotcontrol
by wrapping the content in <div class="dialog"></div>
to serve as dialog body. Then we add <div class="overlay" />
to serve as an modal dialog overlay.
Of course we need to define the styles for the dialog to function correctly on the page. For that we need to define our css classes overlay
and dialog
. You can do it in the same control file, but since the CSS ca be reused for all dialogs usually we but it in the separate css file.
.dialog-overlay {
z-index: 100;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #aaaaaa;
opacity: .3;
}
High z-index
will make sure our dialog and overlay is displayed well above content on the page position: fixed; top: 0; left: 0; width: 100%; height: 100%;
will make our overlay cover the whole page corner to corner.
.dialog {
z-index: 101;
position: absolute;
top: 35%;
left: 35%;
height: auto;
width: 30%;
border: 1px solid #999;
border-radius: 4px;
background-color: #fff;
padding: 10px;
}
The z-index
needs to be above the value in .dialog-overlay
. position: absolute; top: 35% left: 35%; width: 30%;
is here to display the dialog centered on the page.
The ProjectDialog.dotcontrol
control should at this point be a viable dialog, now we need to add it to the Projects.dothtml
page:
<dot:Content ContentPlaceHolderID="MainContent">
<cc:ProductDialog DataContext={value: Dialog} />
<table id="products-table">
...
</table>
</dot:Content>
For the dialog data we add Dialog
property in the ProductsViewModel
:
public ProductDialogViewModel Dialog { get; set; } = new ProductDialogViewModel();
Step 4: Loading the data
Now, when we have the UI ready, we can migrate the data loading. We have to load the data for the page itself, and we need to load the data for the dialog.
Let's look at products.js
. On line 21
, we make a GET
request to ProductsService.aspx
with no parameters to load JSON with list of projects;
$.get("/ProductsService.aspx", {},
function (products) { ... });
On line 32
, we can see another request this time with parameters detail
and id
.
$.get("/ProductsService.aspx", {action: "detail", id: id},
function (product) { ... });
We need to look at ProductsService.aspx
and see the ASPX page code behind implementation. (ASPX markup just displays Json
property as literal.)
public partial class ProductsService : Page
{
private readonly ProductFacade facade;
public ProductsService()
{
facade = new ProductFacade();
}
public string Json { get; set; }
protected void Page_Load(object sender, EventArgs e)
{
var context = HttpContext.Current;
var action = context.GetQuery("action");
var id = context.GetIntQuery("id");
if (action == "detail" && id > 0)
{
Json = JsonConvert.SerializeObject(facade.Get(id) ?? new object());
}
else
{
Json = JsonConvert.SerializeObject(facade.List());
}
}
}
This time we are lucky and both the detail operation and the list operations are nicely separated in the ProductFacade
. In many projects where old ASPX pages are used as API to get JSON data, the actions could be one tangled mess. In that case, we would have to refactor the page and create our facade and refactor the code-behind close to something we can see here. That however would be whole another can of worms we are not going to open here.
Now we know how ProductsService.aspx
works, and we can take ProductFacade
and reference it in our ProductsViewModel
. We can take the facade.List()
that is used for the GET
request with no parameters and use it to load the products in PreRender
function of the ProductsViewModel
viewmodel. There is no point in loading the project in JavaScript. We can load them in C# and have type safety and better error checking.
public ProductsViewModel()
{
facade = new ProductFacade();
}
public override Task PreRender()
{
if(!Context.IsPostBack) {
Products = facade.List().ToList();
}
return base.PreRender();
}
Notice that we only load the products on the first load of the page because DotVVM will take care of refilling the Products
property on postbacks.
To load the dialog data on demand we can use DotVVM UI service in combination with static command.
The job of ProductsService.aspx
will be taken by newly created ProductsUiService
. The DotVVM UI Services can be referenced from the DotVVM pages by using @service
directive. We can call a method of the UI service from static command binding and assign the result to Dialog
property. This solution is ideal to load data od demand without needing to do a full postback.
public class ProjectsUiService
{
private readonly ProductFacade facade;
public ProjectsUiService()
{
facade = new ProductFacade();
}
[AllowStaticCommand]
public ProductDialogViewModel GetDialog(int id)
{
var project = facade.Get(id);
return new ProductDialogViewModel
{
Code = project.Code,
Description = project.Description,
IsVisible = true,
Name = project.Name,
Price = project.Price
};
}
}
The UI service is just simply uses the ProductFacade
from ProductsService.aspx
uses Get(...)
to get the project and creates the dialog viewmodel. Notice that methods of the UI service intended to be called from static commands need to have [AllowStaticCommand]
attribute.
To be able to reference the UI service in the DotHTML markup we need to register it in the DotvvmStartup
.
public void ConfigureServices(IDotvvmServiceCollection services)
{
...
services.Services.AddTransient<ProjectsUiService>();
}
Finally, we can reference the service in the Projects.dothtml
:
@service productsService = DotVVM.Samples.Migrated.Pages.Products.ProductsUiService
On line 80
of products.js
, we see a JQuery event registration:
$('#products-table tbody').on('click', 'tr', function (sender, event) { ... });
We can do the same job as JQuery .click()
directly in our Products.dothtml
markup file. We can use DotVVM static command binding. Having the Click
binding directly in the markup is more intuitive for anyone reading the code. The JQuery .click(...)
references a tr
element inside of products-table tbody
. Base on this we locate equivalent element in our Products.dothtml
page, and add the binding like so:
<tr Events.Click={staticCommand: _root.Dialog = productsService.GetDialog(Id)}>
The static command binding will call GetDialog
of the UI service on the server side (without doing full post back) and sets property Dialog
in the root viewmodel on the client side.
Conclusion
By wiring in the click event of the table row the migration of our test page is complete. For our migrated DotVVM sample we crated new files:
Migrated
Pages
Controls
ProductDialog.dotcontrol
ProductDialogViewModel.cs
Products.dothtml
ProductsViewModel.cs
ProductsUiService.cs
On top of that we reused ProductsFacade.cs
. We don't need projects.js
as we migrated all the JavaScript logic into C# and DotHTML. We also don't need ProjectsService.aspx
. We replaced its functionality with ProductDialogViewModel
and ProductsUiService
.
This migration was a bit more complicated in a sense that it is not possible to just mechanically do text replaces. The principles and patterns used here however can be used generally.
In particular, the way to migrate underscore.js
templates and then turn them into either DotVVM markup controls, or put them as a content of DotVVM template property of a control like <dot:Repeater>
.
Another useful pattern is that we can replace on-demand data loading in JavaScript like JQuery .get(...)
by using DotVVM UI Services in tandem with static command bindings to load the data on demand without full post back.