App Modernization - Part #4: Migrate ASCX components

|
Publikováno:

This article is the last part of the series:

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

ASP.NET WebForms control

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 SelectItems. 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 use IncludeInPage 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 Ids and Names 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.

Tomáš Herceg
Tomáš Herceg

BIO: 

I am the CEO of RIGANTI, small software development company located in Prague, Czech Republic.

I am a Microsoft Regional Director and Microsoft Most Valuable Professional.

I am the author of DotVVM, an open source .NET-based web framework which lets you build Line-of-Business applications easily and without writing thousands lines of Javascript code.

Ostatní články z kategorie: DotVVM Blog