App Modernization - Part #4: Migrate ASCX components
Published: 12/12/2023 9:33:54 AMThis article is the last 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
- App Modernization - Part #3: Migrate ASCX components
- App Modernization - Part #4: Migrate ASCX components
We will return to our fictional e-shop pages written in ASP.NET Web Forms. This article is the second of two articles migrating the ProductDetail
page.
We are going to migrate a bit more complex control which contains several pitfalls that you may encounter in legacy applications. We will be building on the sample from the previous article. We will also need to be familiar with DotProperties
and DotVVM controls.
You can follow this this migration by cloning the repo https://github.com/riganti/dotvvm-samples-webforms-advanced.
The overview
The control is list of categories for a product detail. The control supports:
- editing all categories at once
- ordering the categories in ascending or descending order
- adding a new category
- validation of duplicates and empty entries
Let's have a look at he control markup:
<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="ProductCategories.ascx.cs" Inherits="DotVVM.Samples.Controls.ProductCategories" %>
<div class="categories">
<div class="filter">
<%= GetSortSelect() %>
<asp:Button UseSubmitBehavior="true" ID="SortButton" Text="Sort categories" runat="server" />
</div>
<span id="ValidationMessageSpan" class="error" runat="server"></span>
<asp:Repeater ID="CategoryRepeater" runat="server">
<ItemTemplate>
<div class="product-category <%# (bool)Eval("IsError") ? "category-error": "" %>">
<asp:HiddenField ID="CategoryIdField" runat="server" Value='<%# Eval("Id") %>' />
<asp:TextBox ID="CategoryNameField" runat="server" Text='<%# Eval("Name") %>' />
</div>
</ItemTemplate>
</asp:Repeater>
<div class="product-category add-category" id="AddCategoryPanel" runat="server">
<asp:TextBox name="newCategory" ID="NewCategoryTextBox" runat="server" CssClass="form-control" />
<asp:Button ID="AddButton" runat="server" CssClass="form-control" Text="Add" OnClick="AddButton_Click" />
</div>
</div>
From the first look we can see:
- Section for sorting the categories
- Repeater for rendering the inputs with category names
- Section for adding new categories
Next let's inspect the code behind. As fields we have:
private readonly ProductDetailFacade _facade;
private bool _sortDescending;
_facade
for loading categories from the database. _sortDescending
stores the sorting direction during the post-back.
public int ProductId { get; set; }
protected List<Category> Categories { get; private set; } = new List<Category>();
We have the Categories
property which stores the categories loaded from database or from post-back and serves as a data source for the repeater. The ProductId
is set from parent page and contains the ID of current product.
public override void DataBind()
{
PrepareCategories();
ValidateCategories();
BindControlData();
base.DataBind();
}
The most important method for us is DataBind
which is called from parent page. It loads the categories from the database or from post-back data using PrepareCategories
method. Then, it validates the categories and binds the categories to the Repeater control. Even if we aim to keep changes to the backend logic to the minimum, we have to make some changes here, because in DotVVM, the data are bound to the controls automatically.
public List<Category> GetCategories() { ... }
GetCategories
is a method the parent page calls on save, to get current categories from the control to save them.
protected void AddButton_Click(object sender, EventArgs e) { ... }
As for methods that are used in the markup, we have the “add category” click handler. From the examples of migrations we have done before, we know that these handlers can be usually easily migrated as viewmodel methods. That is exactly what we are going to do now:
protected string GetSortSelect()
{
return GetSelectControl("categoriesDesc",
_sortDescending,
new SelectItem[] {
new SelectItem { Text = "Ascending", Value = false },
new SelectItem { Text = "Descending", Value = true },
});
}
The last protected method is quite an example of a legacy code. Sometimes, during the migration, we would come across piece of code constricting a piece of HTML to be rendered in the page. Such controls constructed on in the code-behind were used for various reasons. Often, links with query parameters in the href
, headers for table columns, or a common example of that is a generated select list. This method renders the select
tag with options as a string to render in the page.
We do not need to go into the private methods for now, a brief overview should be enough to inform our strategy for migrating the control. As there is quite lot of logic, simply using DotVVM control properties as before will not be enough here. Controls like this one need the viewmodel. Having a dedicated viewmodel that would contain the data and the logic of our control is often the best way to go.
Scaffolding
We can now do the basic setup for the new control with viewmodel. We create new DotVVM markup control ProductCategories.dotcontrol
in its own folder called ProductCategories
. In the same folder, we create a viewmodel for the control: ProductCategoriesViewModel
. The viewmodel will contain the logic migrated from the originals ASCX control code-behind. In this case, there is no need to create DotVVM code-behind class for ProductCategories.dotcontrol
control. Instead of passing the data to the control using DotVVM properties, you can pass everything through the viewmodel.
@viewModel DotVVM.Samples.Migrated.Controls.ProductCategories.ProductCategoriesViewModel
<div class="categories">
</div>
We should have a skeleton control file like this, an empty class for the viewmodel, and we must not forget to register the control in the DotvvmStartup.RegisterControls
method:
config.Markup.AddMarkupControl(
"cc",
"ProductCategories",
"Migrated/Controls/ProductCategories/ProductCategories.dotcontrol");
Now, we can add the control to the parent ProductDetail
page. The ProductCategoriesViewModel
must be nested inside of the parent page, so we add a property CategoriesModel
to ProductDetailViewModel
. That is because ProductDetailViewModel
is the viewmodel of the parent page from which ProductCategories
control is referenced:
public class ProductDetailViewModel : SiteViewModel
{
public int ProductId { get; set; }
public List<Tag> Tags { get; set; }
public ProductCategoriesViewModel CategoriesModel { get; set; }
// ...
}
Note that we have ProductId
and Tags
properties from previous migration of ProductTags
control. Now that we have created a property of ProductCategoriesViewModel
, we can add the ProductCategories
control into the markup of ProductDetail
page and bind it like so:
<cc:ProductCategories DataContext={value: CategoriesModel} />
Notice that the data context in which ProductCategories
control is used must match the viewmodel defined by @viewmodel
directive in the ProductCategories.dotcontrol
markup file.
To change the data context for the control tag in ProductDetail
page markup, we can use the DataContext
property and bind it to the property in the viewmodel of the correct type. In this case, it’s the CategoriesModel
property of ProductDetailViewModel
.
Migrating the sorting
Now that we have basic scaffolding in place, we can start migrating the markup and the backend. Let's take a look at the sort direction ComboBox control:
<div class="filter">
<%= GetSortSelect() %>
<asp:Button UseSubmitBehavior="true" ID="SortButton" Text="Sort categories" runat="server" />
</div>
We have the GetSortSelect
method which renders the select with options based on the provided SelectItem
s. The control dot:ComboBox
helps us here, but we still need to have a look how the selected value is used co we can replicate it in our DotVVM control. In the ASPX code behind, there is a private field _sortDescending
. After searching for its references, we can see:
return GetSelectControl("categoriesDesc",
_sortDescending,
new SelectItem[] {
new SelectItem { Text = "Ascending", Value = false },
new SelectItem { Text = "Descending", Value = true },
});
This code selects the value in the control on the post-back, but the dot:ComboBox
control does this by default. We can take the SelectItem
array and move it to ProductCategoriesViewModel
. We can also see that we need to create property SortDescending
in the viewmodel to hold the selected value of the combobox.
public class ProductCategoriesViewModel : SiteViewModel
{
public bool SortDescending { get; set; }
public SelectItem[] SortingOptions { get; } =
new SelectItem[] {
new SelectItem { Text = "Ascending", Value = false },
new SelectItem { Text = "Descending", Value = true },
};
}
SortingOptions
property has only getter because we don't want to make changes to the sorting options on client side and send them back to server during post-back.
Next reference in the original ASCX control code behind is _sortDescending = HttpContext.Current.GetBoolQuery("categoriesDesc");
This is just a way to get value of the sorting direction during ASP.NET Web Forms post-back, DotVVM does this by default when deserializing viewmodel on server side.
Other references are just usages, those are simple to deal with, when we copy the logic over we bulk replace _sortDescending
to SortDescending
in the DotVVM ProductCategories
control.
Now we migrate the original ASCX markup:
<div class="filter">
<%= GetSortSelect() %>
<asp:Button UseSubmitBehavior="true" ID="SortButton" Text="Sort categories" runat="server" />
</div>
To DotVVM markup like this:
<form class="filter">
<dot:ComboBox DataSource={value: SortingOptions}
SelectedValue={value: SortDescending}
ItemTextBinding={value: Text}
ItemValueBinding={value: Value == true} />
<dot:Button ID="SortButton" Click={command: null} Text="Sort categories" IsSubmitButton=true />
</form>
SortingOptions
and SortDescending
properties are bound to DataSource
and SelectedValue
respectively. It is the collection of options and the selected value. Data context for ItemTextBinding
and ItemValueBinding
properties is an item from the collection bound to the DataSource
property, in this case the class SelectItem
. We use ItemTextBinding
and ItemValueBinding
DotProperties to select members of the class SelectItem
which should be used as a value and a text description for the combobox items.
Now to the little hack I made here {value: Value == true}
: The issue is that ItemValueBinding
control property only supports primitive types. However, we have object
as a type of our Value
here because in the legacy system the implementation was not using generics. The syntax of data-bindings in DotVVM does not support casting, but since we know that only true
and false
values appear there, comparing to true
is safe enough. This is unfortunate, but there is a better solution than rewriting every single reference to the SelectItem.Value
in the code-behind.
The binding of the sort button Click={command: null}
may seem strange. What it does is it invokes the DotVVM post-back without calling any command in the viewmodel. Since we are refreshing the data on every postback, there is no need to call any command. Notice also the IsSubmitButton=true
property which tells DotVVM to render the button as a submit button. When such button is used in a form
element, it will be activated automatically when the user presses the Enter key.
Migrating the categories repeater
Migrating the code-behind
Migrating the main Repeater and logic for categories is the main challenge for us. In the legacy applications, it is usually the main grid or repeater with the main collection that has the most logic on the back-end surrounding it. In cases such as these, especially if the logic gets complicated, it would be very time consuming to rewrite everything perfectly into for our viewmodel.
Instead, we can copy the logic over, and touch only the parts that we absolutely need to change. So the two methods from ProductCategories.ascx.cs
that we can just copy over to our DotVVM viewmodel are:
public override void DataBind()
{
PrepareCategories();
ValidateCategories();
BindControlData();
base.DataBind();
}
public List<Category> GetCategories()
{
return Categories.OrderBy(c => c.Id).ToList();
}
For the DataBind
method, we have to get rid of the override
keyword and delete base.DataBind();
.
Both methods are called from the parent page. Here we are lucky that the reading data from controls, validating, and binding data back to the controls are nicely split apart. We may not be so lucky on some legacy projects. However, the parts that need to be changed are usually semantically the same, however deep in the code they are.
Before anything else, we need to bring over our Categories
property from ProductCategories.ascx.cs
:
protected List<Category> Categories { get; private set; } = new List<Category>();
We need to make changes as we include it to the ProductDetailViewModel
:
public List<Category> Categories { get; set; } = new List<Category>();
It needs to be a public property with both getter and setter public to have the DotVVM fill it on post-back.
From the DataBind
method, the ValidateCategories
method we can simply copy over from ProductCategories.ascx.cs
into ProductCategoriesViewModel
.
When we look at BindControlData
in the ASCX code behind we see:
private void BindControlData()
{
CategoryRepeater.DataSource = Categories;
CategoryRepeater.DataBind();
if (Categories.Any(c => c.IsError))
{
ValidationMessageSpan.Visible = true;
ValidationMessageSpan.InnerText = "Some categories are invalid";
}
}
We can see something like this in many legacy Web Forms applications. Here we bind the categories to the Repeater, and the eventual validation message to the span control. In DotVVM, the dot:Repeater
is bound in the markup and does not need any work done in the code-behind. So we do not need first 2 lines of the BindControlData
method.
However, we do need a property to hold the validation message. In DotVVM we do not reference controls on the page from the code-behind. Instead we create property to hold a validation message and then bind the property in the DotVVM markup to the span InnerText
property.
public string ValidationMessageSpanText { get; private set; }
So, we created the property in ProductCategoriesViewModel
. The property has a private setter because we do not need it to be transferred from client side to server side. Now the migrated BindControlData
method will look like this:
private void BindControlData()
{
if (Categories.Any(c => c.IsError))
{
ValidationMessageSpanText = "Some categories are invalid";
}
}
Generally, when migrating parts of Web Forms code-behind that bind data to the controls on page these rules apply:
- Setting
DataSource
for repeaters, grids, item lists and other can be discarded from code-behind and moved to DotVVM markup - For
Visible
properties we can useIncludeInPage
DotProperty in the DotVVM markup, define property to store the visibility value in the viewmodel for complex cases. - For setting values and texts: if there already is a property we can just go ahead and set the property. This is usually the case for text boxes, checkboxes, combo boxes and other such controls.
- For text on spans, labels and similar we need to create a property in the viewmodel and bind it to the
InnerText
property of the corresponding control tag in the DotVVM markup.
The last on our list is the PrepareCategories
method where data is loaded into the Categories
.
private void PrepareCategories()
{
if (!IsPostBack)
{
Categories = ProductId > 0
? _facade.GetCategories(ProductId)
: new List<Category>();
}
else
{
Categories = ReadCategories().ToList();
}
_sortDescending = HttpContext.Current.GetBoolQuery("categoriesDesc");
Categories = _sortDescending
? Categories.OrderByDescending(x => x.Name).ToList()
: Categories.OrderBy(x => x.Name).ToList();
}
On first load of the page, the categories are loaded from the database. On post-back, they are read from the view-state. The ReadCategories
used here just reads the Id
s and Name
s from asp:TextBox
and asp:HiddenField
in the asp:Repeater
. It parses the Id
as int value and creates a Category object for each repeater item.
DotVVM framework transfers the data on post-back and deserializes them into the viewmodel automatically. Therefore in these Web Forms application, when we see a code that parses data from view-state or query parameters, we can usually get rid of it during a migration to DotVVM.
In our case, we can safely throw away whole else
branch.
Likewise, we can discard the line where _sortDescending
is being loaded from query. We have already dealt with sorting before and now we have SortDescending
property in our viewmodel that holds the sorting direction for us. Only change we need to make is replace _sortDescending
to SortDescending
for sorting categories.
We need to change !IsPostBack
into !Context.IsPostBack
. We have Context
property in our viewmodel as it extends DotvvmViewModelBase
. The Context
property is injected automatically by DotVVM. From DotVVM version 4, the Context
property is injected automatically as long as the viewmodel instance is created before the Init
stage of page lifecycle.
For us it means we need to create instance of ProductCategoriesViewModel
in the constructor of ProductDetailViewModel
The resulting migrated method looks like this:
private void PrepareCategories()
{
if (!Context.IsPostBack)
{
Categories = ProductId > 0
? _facade.GetCategories(ProductId)
: new List<Category>();
}
Categories = SortDescending
? Categories.OrderByDescending(x => x.Name).ToList()
: Categories.OrderBy(x => x.Name).ToList();
}
We have still some work to do. We need to create property ProductId
that will get its value from the query parameter productId
. In DotVVM this is easy. We just add the property to our viewmodel and decorate it with FromQuery
attribute. If you are using MVC in your application, make sure you reference FromQuery
attribute from the DotVVM namespace.
public class ProductCategoriesViewModel : SiteViewModel
{
// ...
[FromQuery("productId")]
public int ProductId { get; set; }
// ...
}
The life-cycle requirements is the same as with Context
property. As long as the viewmodel instance exists in the root viewmodel (in our case ProductDetailViewModel
) before the Init
phase of the page, the ProductId
property will be filled by DotVVM with the value from the URL query string.
The last unknown identifier in our migrated PrepareCategories
method is _facade
. Depending on the application, we can use dependency injection, or in our case we can just create the instance in the ProductDetailViewModel()
constructor. Because it is a service dependency and we do not want DotVVM to serialize it, we make it a private
readonly
field.
public ProductCategoriesViewModel()
{
_facade = new ProductDetailFacade();
}
All the code-behind logic for loading categories is now migrated, we can migrate the markup.
Migrating the control markup
We can copy over the the <asp:Repeater ... >
from ProductCategories.ascx
into the ProductCategories.dotcontrol
. We change change all the asp
prefixes for dot
. We delete all the runat="server"
attributes. Also we do not need the asp:HiddenField
because DotVVM takes care of our page data. We should have something like this:
<dot:Repeater ID="CategoryRepeater">
<ItemTemplate>
<div class="product-category <%# (bool)Eval(" IsError") ? "category-error" : "" %>">
<dot:TextBox ID="CategoryNameField" Text='<%# Eval("Name") %>' />
</div>
</ItemTemplate>
</dot:Repeater>
Next we change Web Forms code islands into bindings. '<%# Eval("Name") %>'
becomes {value: Name}
. For conditionally adding CSS class we can use DotVVM property group Class-*
. The product-category
class will be present always so we put in Class-product-category="true"
. The category-error
we put in binding: Class-category-error={value: IsError}
. Based on these group properties, DotVVM will construct and update the class
attribute as needed.
The Repeater needs a data source so we create a DataSource
binding and bind it to Categories
collection.
<dot:Repeater ID="CategoryRepeater" DataSource={value: Categories}>
<ItemTemplate>
<div Class-product-category="true" Class-category-error={value: IsError}>
<dot:TextBox ID="CategoryNameField" Text={value: Name}/>
</div>
</ItemTemplate>
</dot:Repeater>
Not to forget the validation message. We add a <span>
and add IncludeInPage
property. DotVVM value bindings support string.IsNullOrEmpty()
method so we can use it to check when to display the span. We also add InnerText
binding on the span as discussed earlier.
<span
IncludeInPage={value: string.IsNullOrEmpty(ValidationMessageSpanText)}
InnerText={value: ValidationMessageSpanText} />
Now the migration of category Repeater logic is complete. To make the control work within the page, we have to call ProductCategoriesViewModel.BindData()
in the ProductDetailViewModel
. The BindData()
is designed to be called by a parent page.
public class ProductDetailViewModel : SiteViewModel
{
// ...
public override Task Load()
{
//...
Categories.DataBind();
return base.Load();
}
}
Notice we are using Load()
method here. Load()
is called before commands from command bindings are invoked. Calling DataBind
here ensures categories are validated and ready when eventually Save()
, Add()
or other commands are invoked.
Migrating Add new category
Taking look at the "Add Category" section, the last section we have to migrate:
<div class="product-category add-category" id="AddCategoryPanel" runat="server">
<asp:TextBox name="newCategory" ID="NewCategoryTextBox" runat="server" CssClass="form-control" />
<asp:Button ID="AddButton" runat="server" CssClass="form-control" Text="Add" OnClick="AddButton_Click" />
</div>
We change asp
prefixes into dot
prefixes. We remove all runat
attributes. We change the CssClass
to ordinary class
attribute. We add a Text
property for the dot:TextBox
with a new value binding.
We need to create a property for the textbox text in the viewmodel. When naming the property, we can take inspiration from the textbox ID and we can call it NewCategoryTextBoxText
.
For the dot:Button
we change the OnClick
to Click
and add a new command binding. Taking inspiration from the ID we name the command AddButtonClick
.
The migrated section should look something like this:
<div class="product-category add-category" id="AddCategoryPanel">
<dot:TextBox name="newCategory"
ID="NewCategoryTextBox"
class="form-control"
Text={value: NewCategoryTextBoxText} />
<dot:Button ID="AddButton"
class="form-control"
Text="Add"
Click={command: AddButtonClick()} />
</div>
Of course, the property NewCategoryTextBoxText
and the method AddButtonClick()
do not exist in the viewmodel at this point. With the commercial version of DotVVM for Visual Studio, we can place the caret over the symbols and use CTRL+.
to shows actions to create a new text property and a new method respectively. Otherwise, we can just create them ourselves in our viewmodel.
The logic for adding the category in the backend will stay very similar. Let's have a look:
protected void AddButton_Click(object sender, EventArgs e)
{
Categories.Add(new Category
{
Id = Categories.Count + 1,
Name = NewCategoryTextBox.Text
});
NewCategoryTextBox.Text = "";
ValidateCategories();
BindControlData();
}
We can copy the content of the AddButton_Click
from the ProductCategories.ascx.cs
file into our newly created AddButtonClick
of ProductCategoriesViewModel
.
The only error that shows up is that NewCategoryTextBox
does not exist. The cure for this error is simple - we have already prepared NewCategoryTextBoxText
property. We can just change NewCategoryTextBox.Text
into NewCategoryTextBoxText
and everything should work.
The result is pretty similar to what it was in the ProductCategories.ascx.cs
backend.
public void AddButtonClick()
{
Categories.Add(new Category
{
Id = Categories.Count + 1,
Name = NewCategoryTextBoxText
});
NewCategoryTextBoxText = "";
ValidateCategories();
BindControlData();
}
The control ProductCategories
is now migrated to DotVVM.
ProductDetail page
To finish the ProductDetail
page, we need to migrate the save functionality from ProductDetail.ascx
. The relevant part of the ASPX markup is:
<asp:Button UseSubmitBehavior="true" runat="server" Text="Save" OnClick="Save_Click" />
We migrate the "Save" button same way we migrated the buttons before and place it into the ProductDetail.dothtml
:
<dot:Button Text="Save" Click={command: Save()} />
We create Save()
method in the ProductDetailViewModel
. Then we copy the content of the Save_Click
method from ProductDetail.aspx.cs
over:
public void Save()
{
var categories = ProductCategoriesControl.GetCategories();
if (!categories.Any(c => c.IsError))
{
_facade.SaveCategories(_productId, categories);
}
else
{
Message = "Cannot save.";
}
}
We have no ProductCategoriesControl
in our viewmodel, but we do have Categories
property with the viewmodel of our newly migrated ProductCategories
control. So we change ProductCategoriesControl
to Categories
.
We also do not have _productId
, but we do have ProductId
in ProductDetailViewModel
. We can change that.
We have already created the Message
property when we migrated the Tags
control, so the message should work out of the box.
Migrated Save
method is now finished and looks like this:
public void Save()
{
var categories = Categories.GetCategories();
if (!categories.Any(c => c.IsError))
{
_facade.SaveCategories(ProductId, categories);
}
else
{
Message = "Cannot save.";
}
}
Notice here that the usage of GetCategories()
method that was defined on the original ProductCategories.ascx
control. We have migrated the method to the viewmodel of ProductCategories
DotVVM control, because the viewmodel is what holds the data in DotVVM.
Another important side note: the property ProductId
we are using in this save method is decorated with [FromQuery("productId")]
attribute. By using the attribute DotVVM knows to fill ProductId
with the value from the query string.
Conclusion
This concludes the migration of the ProductCategories
and the associated ProductDetail
page. We have explored possibility of migrating complex logic in control code-behind into DotVVM control with the least amount of changes to the logic itself.
We have found that the best way to do it is to introduce a viewmodel with serves as the data context for the migrated control and also holds the logic migrated from ASCX control code behind.
We have also shown how to integrated the control viewmodel into the parent page.
I am Software Engineer at RIGANTI. I participate in the development of the DotVVM Framework, and I take care of the DotVVM for Visual Studio extension. I was also involved in several projects where DotVVM was used to modernize ASP.NET Web Forms applications.