Rockford Lhotka's Blog

Home | Lhotka.net | CSLA .NET

 Wednesday, 27 June 2018
« Service endpoint contracts | Main |

A while back I blogged about how to edit a collection of items in ASP.NET MVC.

These days I've been starting to use Razor Pages, and I wanted to solve the same problem with the newer technology.

In my case I'm also making sure the new CSLA .NET 4.7.200 CslaModelBinder type works well in this, among other, scenarios.

To this end I wrote a CslaModelBinderDemo app.

Most of the interesting parts are in the Pages/MyList directory.

Though this sample uses CSLA, the same concepts should apply to any model binder and collection.

My goal is to be able to easily add, edit, and remove items in a collection. I was able to implement the edit and remove operations on a single grid-like page.

I chose to do the add operation on a separate page. I first implemented it on the same page, but in that implementation I ran into complications with business rules that make a default/empty new object invalid. By doing the add operation on its own page there's no issue with business rules.

Domain Model

Before building the presentation layer I created the business domain layer (model) using CSLA. These are just two types: a MyList editable collection, and a MyItem editable child type for the objects in the collection.

The MyItem type is a little interesting, because it implements both root and child data portal behaviors. This is because the type is used as a child when in a MyList collection, but is used as a standalone root object by the page implementing the add operation. In CSLA parlance this is called a "switchable object".

Configuring the model binder

In the Razor Pages project it is necessary to configure the app to use the correct model binder for CSLA types. The default model binders for MVC and now .NET Core all assume model objects are dumb DTO/entity types - public read/write properties, no business rules, etc. Very much not the sort of model you get when using CSLA.

The new CslaModelBinder for AspNetCore fills the same role as this type has in previous ASP.NET MVC versions, but AspNetCore has a different binding model under the hood, so this is a totally new implementation.

To use this model binder add code in Startup.cs in the ConfigureServices method:

      services.AddMvc(config =>
        config.ModelBinderProviders.Insert(0, new Csla.Web.Mvc.CslaModelBinderProvider(CreateInstanceAsync, CreateChild))
        ).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

An app can have numerous model binders. The model binder providers indicate which types a binder should handle. So the CslaModelBinderProvider ensures that the CslaModelBinder is used for any editable business object types (basically BusinessBase or BusinessListBase subclasses).

Notice that two parameters are provided to CslaModelBinderProvider: something to create root objects, and something to create child objects.

These are optional. If you don't provide them, CslaModelBinder will directly create instances of the appropriate types. But if you want to have some control over how the instances are created then you need to provide these parameters (and related implementations).

Root and Child instance creators

In my case I want to make sure when my root collection is instantiated, that it contains all existing data.

Remember that the model binder is invoked on page postback, when the data is flowing from the browser back into the Razor Page on the server. All the collection data is in the postback, but it also exists in the database.

Basically what we're doing in this scenario is merging the changed data from the browser into the data from the database. I could maintain the collection in some sort of Session store, but in this app I'm choosing to load it from the database each time:

    private async Task<object> CreateInstanceAsync(Type type)
    {
      object result;
      if (type.Equals(typeof(Pages.MyList.MyList)))
        result = await Csla.DataPortal.FetchAsync<Pages.MyList.MyList>();
      else
        result = Csla.Reflection.MethodCaller.CreateInstance(type);
      return result;
    }

Of course the collection contains child objects, and the postback provides an array of data, with each row in the array corresponding to an object that exists in the collection.

On postback, step 1 is that the root collection gets created (via the FetchAsync call), and then each row in the postback array needs to be mapped into an existing (or new) child object in the collection.

The CreateChild method grabs the Id value for the current row from the postback and uses that value to find the existing child object in the collection. If that child exists it is returned to CslaModelBinder for binding. If it isn't in the collection then a new instance of the type is created so that child can be bound and added to the collection.

    private object CreateChild(System.Collections.IList parent, Type type, Dictionary<string, string> values)
    {
      object result = null;
      if (type.Equals(typeof(Pages.MyList.MyItem)))
      {
        var list = (Pages.MyList.MyList)parent;
        var idText = values["Id"];
        int id = string.IsNullOrWhiteSpace(idText) ? -1 : int.Parse(values["Id"]);
        result = list.Where(r => r.Id == id).FirstOrDefault();
        if (result == null)
          result = Csla.Reflection.MethodCaller.CreateInstance(type);
      }
      else
      {
        result = Csla.Reflection.MethodCaller.CreateInstance(type);
      }
      return result;
    }

The result is that CslaModelBinder "creates" a new collection, but really it gets a pre-loaded instance with current data. Then it "creates" a new child object for each row of data in the postback, but really it gets pre-existing instances of each child object with existing data, and then the postback data is used to set each property on the object.

The beauty here is that if the postback value is the same as the value already in the child object's property, then CSLA will ignore the "new" value. But if the values are different then the child object's IsDirty property will be true so it will be saved to the database.

Adding a new child to the collection

It is certainly possible to add a new child object to the collection like I did in the previous ASP.NET MVC blog post. The drawback to that approach is that this new child may have business rules that complicate matters if it is created "blank" and added to the list.

So in this case I decided a better overall experience might be to have the user add an item via a create page, and do edit/remove operations on the index page.

The Create.cshtml page is perhaps the simplest scenario. The Razor was created by scaffolding. Nothing in this view is unique to this problem space or CSLA. It is just a standard create page.

The Create.cshtml.cs code behind the page is a little different from code you might find for Entity Framework, because I'm using CSLA domain objects. This just means that the OnGet method uses the data portal to retrieve the domain object.

    public async Task<IActionResult> OnGet()
    {
      MyItem = await Csla.DataPortal.CreateAsync<MyItem>();
      return Page();
    }

And the OnPostAsync method calls the SaveAsync method to save the domain object.

    public async Task<IActionResult> OnPostAsync()
    {
      if (!ModelState.IsValid)
      {
        return Page();
      }

      MyItem = await MyItem.SaveAsync();

      return RedirectToPage("./Index");
    }

Finally, the MyItem property is a standard data bound Razor Pages property.

    [BindProperty]
    public MyItem MyItem { get; set; }

The important thing to understand is that MyItem is a subclass of BusinessBase and so the CslaModelBinderProvider will direct data binding to use CslaModelBinder to do the binding for this object. Because CslaModelBinder understands how to correctly bind to CSLA types, everything works as expected.

Editing and removing items in the collection

Now we get to the fun part: creating a page that displays the collection's contents and allows the user to edit multiple items, mark items for deletion, and then click a button to commit the changes.

Interestingly enough, the Index.cshtml.cs code isn't complex. This is because most of the work is handled by CslaModelBinder and the two methods we already implemented in Startup.cs. This code just gets the domain object in OnGetAsync and saves it in OnPostAsync.

    [BindProperty]
    public MyList DataList { get; set; }

    public async Task OnGetAsync()
    {
      DataList = await Csla.DataPortal.FetchAsync<MyList>();
    }

    public async Task<IActionResult> OnPostAsync()
    {
      foreach (var item in DataList.Where(r => r.Remove).ToList())
        DataList.Remove(item);
      DataList = await DataList.SaveAsync();
      return RedirectToPage("Index");
    }

Notice how the Remove property is used to identify the child objects that are to be removed from the collection. Because this is a CSLA collection, this code just needs to remove these items, and when SaveAsync is called to persist the domain object's data those items will be deleted, and any changed data will be updated or inserted as necessary.

The Index.cshtml page is a bit different from a standard page, in that it needs to display the input fields to the user, and make sure everything is properly connected to each item in the collection such that a postback can form all the data into an array.

The key part is the for loop that creates those UI elements in a table.

  @for (int i = 0; i < Model.DataList.Count; i++)
  {
    <tr>
      <td>
        <input type="hidden" asp-for="DataList[i].Id" />
        <input asp-for="DataList[i].Name" class="form-control" />
        <span asp-validation-for="DataList[i].Name" class="text-danger"></span>
      </td>
      <td>
        <input asp-for="DataList[i].City" class="form-control" />
        <span asp-validation-for="DataList[i].City" class="text-danger"></span>
      </td>
      <td>
        <input asp-for="DataList[i].Remove" type="checkbox" />
        <label class="control-label">Select</label>
      </td>
    </tr>
  }

Instead of a foreach loop, this uses an index to go through each item in the collection, allowing the use of asp-for to create each UI control.

Make special note of the hidden element containing the Id property. Although this isn't displayed to the user, the value needs to round-trip so it is available to the server as part of the postback, or the CreateChild method implemented earlier wouldn't be able to reconcile existing child object instances with the data in the postback array.

Summary

Quick and easy editing of a collection is a very common experience users expect from apps. Although the standard CRUD scaffolding implements all the right behaviors, as a user it is tedious to edit several rows of data if you have to navigate to multiple pages for each row. The approach in this post doesn't solve every UX need, but when quick editing of multiple rows is required, this is a good answer.

Thanks to Razor Pages data binding, implementing this approach is not difficult.

On this page....
Search
Archives
Feed your aggregator (RSS 2.0)
June, 2018 (4)
May, 2018 (1)
April, 2018 (3)
March, 2018 (4)
December, 2017 (1)
November, 2017 (2)
October, 2017 (1)
September, 2017 (3)
August, 2017 (1)
July, 2017 (1)
June, 2017 (1)
May, 2017 (1)
April, 2017 (2)
March, 2017 (1)
February, 2017 (2)
January, 2017 (2)
December, 2016 (5)
November, 2016 (2)
August, 2016 (4)
July, 2016 (2)
June, 2016 (4)
May, 2016 (3)
April, 2016 (4)
March, 2016 (1)
February, 2016 (7)
January, 2016 (4)
December, 2015 (4)
November, 2015 (2)
October, 2015 (2)
September, 2015 (3)
August, 2015 (3)
July, 2015 (2)
June, 2015 (2)
May, 2015 (1)
February, 2015 (1)
January, 2015 (1)
October, 2014 (1)
August, 2014 (2)
July, 2014 (3)
June, 2014 (4)
May, 2014 (2)
April, 2014 (6)
March, 2014 (4)
February, 2014 (4)
January, 2014 (2)
December, 2013 (3)
October, 2013 (3)
August, 2013 (5)
July, 2013 (2)
May, 2013 (3)
April, 2013 (2)
March, 2013 (3)
February, 2013 (7)
January, 2013 (4)
December, 2012 (3)
November, 2012 (3)
October, 2012 (7)
September, 2012 (1)
August, 2012 (4)
July, 2012 (3)
June, 2012 (5)
May, 2012 (4)
April, 2012 (6)
March, 2012 (10)
February, 2012 (2)
January, 2012 (2)
December, 2011 (4)
November, 2011 (6)
October, 2011 (14)
September, 2011 (5)
August, 2011 (3)
June, 2011 (2)
May, 2011 (1)
April, 2011 (3)
March, 2011 (6)
February, 2011 (3)
January, 2011 (6)
December, 2010 (3)
November, 2010 (8)
October, 2010 (6)
September, 2010 (6)
August, 2010 (7)
July, 2010 (8)
June, 2010 (6)
May, 2010 (8)
April, 2010 (13)
March, 2010 (7)
February, 2010 (5)
January, 2010 (9)
December, 2009 (6)
November, 2009 (8)
October, 2009 (11)
September, 2009 (5)
August, 2009 (5)
July, 2009 (10)
June, 2009 (5)
May, 2009 (7)
April, 2009 (7)
March, 2009 (11)
February, 2009 (6)
January, 2009 (9)
December, 2008 (5)
November, 2008 (4)
October, 2008 (7)
September, 2008 (8)
August, 2008 (11)
July, 2008 (11)
June, 2008 (10)
May, 2008 (6)
April, 2008 (8)
March, 2008 (9)
February, 2008 (6)
January, 2008 (6)
December, 2007 (6)
November, 2007 (9)
October, 2007 (7)
September, 2007 (5)
August, 2007 (8)
July, 2007 (6)
June, 2007 (8)
May, 2007 (7)
April, 2007 (9)
March, 2007 (8)
February, 2007 (5)
January, 2007 (9)
December, 2006 (4)
November, 2006 (3)
October, 2006 (4)
September, 2006 (9)
August, 2006 (4)
July, 2006 (9)
June, 2006 (4)
May, 2006 (10)
April, 2006 (4)
March, 2006 (11)
February, 2006 (3)
January, 2006 (13)
December, 2005 (6)
November, 2005 (7)
October, 2005 (4)
September, 2005 (9)
August, 2005 (6)
July, 2005 (7)
June, 2005 (5)
May, 2005 (4)
April, 2005 (7)
March, 2005 (16)
February, 2005 (17)
January, 2005 (17)
December, 2004 (13)
November, 2004 (7)
October, 2004 (14)
September, 2004 (11)
August, 2004 (7)
July, 2004 (3)
June, 2004 (6)
May, 2004 (3)
April, 2004 (2)
March, 2004 (1)
February, 2004 (5)
Categories
About

Powered by: newtelligence dasBlog 2.0.7226.0

Disclaimer
The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.

© Copyright 2018, Marimer LLC

Send mail to the author(s) E-mail



Sign In