May 29, 2021

Using HTMX with ASP.NET Core: Inline editing

Introduction

In the previous blog post, I demonstrated how you could delete items with AJAX using HTMX and ASP.NET Core with a page refresh when items are deleted. In this blog post, I continue with this series by showing how you can implement an inline editing experience.

The idea is to have a list of customers with an Edit button displayed next to each customer:

Viewing the user detail

When a user clicks on the Edit button, the application swaps out the detail view for an edit form where they can edit some of the customer’s personal information:

Editing a user's information

Clicking on the Save Changes button will save the information and display the detail view again. Clicking on Cancel Changes will cancel all changes and revert back to the detail view.

Displaying the list of customers

We are displaying a list of customers inside an unordered list (<ul>), with each customer displayed as a list item (<li>). On the initial render, we show the customer’s information in a read-only detail view.

Since we will be swapping the content of the <li> for either the edit form or detail view via AJAX, I extract the customer detail view to a Razor partial view. This will make it easier to render either of the partial views from the Razor page handlers - as you will see later in this blog post.

<ul>
    @foreach (var customer in Model.Customers)
    {
        <li>
            <partial name="_CustomerDetail" model="customer"></partial>
        </li>
    }
</ul>

The code for the _CustomerDetail partial view is as follows:

@model Customer

<div class="row align-items-center">
    <!-- Some code omitted for brevity -->
    <div class="col-auto">
        <form asp-page-handler="Edit" asp-route-id="@Model.Id">
            <button type="submit" class="btn btn-sm btn-primary d-none d-md-inline-block">
                Edit
            </button>
        </form>
    </div>
</div>

I have omitted the code displaying the user information from the snippet above, but the important bit to note is that I added a form that posts to the Edit page handler.

The form submission needs to be an AJAX request, and for that, we use the hx-boost attribute of HTMX. Applying this attribute to the outer <ul> element will convert the form in the _CustomerDetail partial contained inside of it, to AJAX an request.

<ul hx-boost="true"
    hx-target="closest li"
    hx-swap="innerHTML">
    @foreach (var customer in Model.Customers)
    {
        <li>
            <partial name="_CustomerDetail" model="customer"></partial>
        </li>
    }
</ul>

The hx-swap attribute tells HTMX to swap out the innerHTML of the target element with the response from the AJAX request. The hx-target attribute tells HTMX that the target element which innerHTML it must swap out is the closest li.

So, what will happen is the following:

  1. The user clicks on the Edit button and submits the form via AJAX
  2. The Razor Page handler (which we will write below) returns a response that contains the new edit form for the user
  3. HTMX finds the closest parent ancestor <li> from the <form> submitted and replace its content with the response from the AJAX request - which will be our new edit form. This will effectively replace the <li> content to display the edit form instead of the detail view.

Display the edit form

Remember that the form that contains the edit button will post to an Edit handler. I create this handler in my PageModel. It will find the user with the corresponding ID from the database and then render the _CustomerForm partial, passing the user along as the model.

public class InlineEditModel : PageModel
{
    public IActionResult OnPostEdit(int id)
    {
        var customer = _database.Customers.First(c => c.Id == id);

        return Partial("_CustomerForm", customer);
    }
}

Next, I create a _CustomerForm partial that will render some text inputs that will allow the user to update the customer information. Since this partial renders inside the <ul> with the hx-boost attribute applied, the form will be submitted via AJAX as well.

@model Customer

<form asp-page-handler="Update" asp-route-id="@Model.Id">
    <div class="form-row">
        <div class="form-group col-md-6">
            <label asp-for="FirstName"></label>
            <input asp-for="FirstName" type="text" class="form-control">
        </div>
        <div class="form-group col-md-6">
            <label asp-for="LastName"></label>
            <input asp-for="LastName" type="text" class="form-control">
        </div>
    </div>
    <div class="form-row">
        <div class="form-group col-md-6">
            <label asp-for="EmailAddress"></label>
            <input asp-for="EmailAddress" type="text" class="form-control">
        </div>
    </div>
    <button type="submit" class="btn btn-sm btn-primary d-none d-md-inline-block">
        Save Changes
    </button>
</form>

Updating information

The final piece of the puzzle is to write the code for the Update handler specified as the asp-page-handler of the edit form above. First, I read the current customer information from the database and update its values from the form. Once that is done, I render the _CustomerDetail partial and return that as the result of the request.

public class InlineEditModel : PageModel
{
    public class CustomerEditModel
    {
        public string FirstName { get; set; }

        public string LastName { get; set; }

        public string EmailAddress { get; set; }
    }
    
    public IActionResult OnPostUpdate(int id, [FromForm] CustomerEditModel model)
    {
        var customer = _database.Customers.First(c => c.Id == id);
        customer.FirstName = model.FirstName;
        customer.LastName = model.LastName;
        customer.EmailAddress = model.EmailAddress;

        return Partial("_CustomerDetail", customer);
    }
}

With this, we can run the application and use the new inline editing experience, as you can see in the short movie below:

Editing a customer

Cancelling the edit

It would be nice to allow a user to cancel out of an edit. So let’s update the _CustomerForm partial and add a Cancel Changes button. As you can see, this is a normal anchor tag that navigates to the Cancel page handler. The hx-boost tag we applied to the outer <ul> tag will also intercept this request and turn it into an AJAX request.

@model Customer

<form asp-page-handler="Update" asp-route-id="@Model.Id">
    <!-- Some code omitted -->
    </div>
    <a asp-route-handler="Cancel" asp-route-id="@Model.Id" class="btn btn-sm btn-secondary d-none d-md-inline-block">
        Cancel Changes
    </a>
    <button type="submit" class="btn btn-sm btn-primary d-none d-md-inline-block">
        Save Changes
    </button>
</form>

For the Cancel page handler, we read the current user information from the database and render the _CustomerDetail partial.

public IActionResult OnGetCancel(int id)
{
    var customer = _database.Customers.First(c => c.Id == id);

    return Partial("_CustomerDetail", customer);
}

With this change in place, a user can now cancel out of an edit operation:

User cancelling an edit operation

The eagle-eyed among you may have noticed that - even though the call was made via AJAX, the URL for the page changed to append ?handler&id=0 to the URL. For some or other reason HTMX insists on pushing the URL for the AJAX request to the history. I am not sure why it does this and even if I use the hx-push-url attribute to explicitly opt out of this, it still does it.

I guess there is a bug somewhere. In any case, there are many ways to skin a cat, so you can simply replace the markup for the Cancel Changes button with the code below, and it will work properly. This code does not make use of hx-boost but explicitly specifies that we want to make an AJAX request to the specified URL by using the hx-get attribute.

<a href="#" hx-get="@Url.Page("", "Cancel", new {id = Model.Id})" class="btn btn-sm btn-secondary d-none d-md-inline-block">
    Cancel Changes
</a>

Validating user input

One last bit before we wrap this up, and that is input validation. Since we are using Razor page handlers, we can use Razor Pages’ normal validation mechanisms. I add a few data annotations attributes to my model and update the Post handler to check whether the ModelState is valid.

If it is invalid, I return the _CustomerForm partial to display the validation errors from the ModelState. If it is valid, I proceed to save the changes and return the _CustomerDetail partial.

public class InlineEditModel : PageModel
{
    public class CustomerEditModel
    {
        [Required]
        public string FirstName { get; set; }

        [Required]
        public string LastName { get; set; }

        [Required]
        [EmailAddress]
        public string EmailAddress { get; set; }
    }
    
    public IActionResult OnPostUpdate(int id, [FromForm] CustomerEditModel model)
    {
        var customer = _database.Customers.First(c => c.Id == id);
        if (!ModelState.IsValid)
        {
            return Partial("_CustomerForm", customer); 
        }
        
        customer.FirstName = model.FirstName;
        customer.LastName = model.LastName;
        customer.EmailAddress = model.EmailAddress;

        return Partial("_CustomerDetail", customer);
    }
}

With that in place, you can see validation in the short movie below:

Adding user validation

Conclusion

In this blog post I demonstrated how you can implement an elegant inline experience for users using HTMX and ASP.NET Core. The beauty of this approach is that you can still use all your normal ASP.NET Core skills without having to resort to a full-blown SPA application.

More of this series

This blog post is part of a series on using HTMX with ASP.NET Core. The full source code for the series can be found at https://github.com/jerriepelser-blog/htmx-with-aspnet-core.

These are all the blog posts in the series:

  1. Introduction
  2. Deleting items from a list
  3. Do a page refresh when deleting items
  4. Inline editing (this blog post)
  5. Infinite scrolling
  6. Real-time search (coming soon)