Developing Your First App with HTMX and .NET: Part VI

Developing Your First App with HTMX and .NET: Part VI

In our previous articles, we used inline forms to register or edit products directly within the page's main content. In this part, we will explore modal forms, which appear in a pop-up overlaying the page's main content, typically dimming or blurring the background. You can download the starting code from the previous article.

To see the modal forms in action, we will implement a soft delete feature for our products, which prompts for a reason during the process. The first step is to define a placeholder for the modal form in the MainPage.razor file. Copy the following lines to the end of the <body> tag:

<div id="modal" class="modal fade">
    <div id="modal-dialog" class="modal-dialog"></div>
</div>

We are using the Bootstrap modal component to create our modal form. The next step is to create a way to invoke our modal form. Let's create a RazorComponents/ShowModal.razor file with the following content:

<li>
    <a class="dropdown-item"
       href="#"
       hx-get=@HtmxAttributes.Endpoint
       hx-target=@HtmxAttributes.Target
       hx-swap=@HtmxAttributes.Swap>@Text</a>
</li>

@code {
    [Parameter, EditorRequired]
    public string Text { get; set; } = default!;
    [Parameter, EditorRequired]
    public HtmxAttributes HtmxAttributes { get; set; } = default!;
}

Add the new component inside the <MenuItems> tag in the Products/EditProductPage.razor file to invoke the endpoint that returns the HTML content of our modal form:

<MenuItems>
    <MenuItem Text="Enable"
              IsDisabled=@(Product.IsEnabled)
              HtmxAttributes=@(new HtmxAttributes($"/products/{Product.ProductId}/enable", "#main", "innerHTML"){Confirm="Are you sure?"}) />
    <MenuItem Text="Disable"
              IsDisabled=@(!Product.IsEnabled)
              Confirm="Are you sure?"
              HtmxAttributes=@(new HtmxAttributes($"/products/{Product.ProductId}/disable", "#main", "innerHTML")) />
    <ShowModal Text="Delete"
               HtmxAttributes=@(new HtmxAttributes($"/products/{Product.ProductId}/delete", "#modal-dialog", "innerHTML")) />
</MenuItems>

Something to note here is that we are using the id of the new div defined in the MainPage.razor file as the HTMX target. Let's continue creating the RazorComponents/ModalForm.razor file as follows:

<div class="modal-content">
    <form hx-post=@HtmxAttributes.Endpoint
          hx-target=@HtmxAttributes.Target
          hx-swap=@HtmxAttributes.Swap
          hx-ext="json-enc"
          hx-indicator="#modal-form-spinner"
          hx-disabled-elt="#modal-form-button"
          hx-select=@HtmxAttributes.Select>
        <div class="modal-header">
            <h1 class="modal-title">@Title</h1>
            <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
        </div>
        <div class="modal-body">
            @Content
        </div>
        <div class="modal-footer">
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
            <button id="modal-form-button" type="submit" class="btn btn-primary">
                <span>Save Changes</span>
                <span id="modal-form-spinner" class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true" />
            </button>
        </div>
    </form>
</div>


@code {
    [Parameter, EditorRequired]
    public RenderFragment? Content { get; set; } = default!;
    [Parameter, EditorRequired]
    public string Title { get; set; } = default!;
    [Parameter, EditorRequired]
    public HtmxAttributes HtmxAttributes { get; set; } = default!;
}

This component is similar to RazorComponents/Form.razor, but uses the Bootstrap modal component. Based on this component, we will create the RazorComponents/DeleteProductPage.razor file as follows:

@using MyApp.RazorComponents;
<ModalForm HtmxAttributes=@(new HtmxAttributes($"/products/{ProductId}/delete", "#main"))
           Title="">
    <Content>
        <div class="row mb-4">
            <div class="col">
                <TextInput Property=@nameof(DeleteProduct.Request.Reason)
                           Label="Reason"
                           maxlength=500
                           placeholder="Enter reason"
                           required />
            </div>
        </div>
    </Content>
</ModalForm>

@code {
    [Parameter, EditorRequired]
    public Guid ProductId { get; set; } = default;
}

The HTMX target will be the main content div. Now, it's time to implement the endpoints to render and process the action. Create the Products/DeleteProduct.cs file with the following content:

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace MyApp.Products;

public static class DeleteProduct
{
    public class Request
    {
        public string? Reason { get; set; }
    }

    public static Task<RazorComponentResult> HandlePage(
        [FromRoute] Guid productId,
        HttpContext context)
    {
        context.Response.Headers.Append("HX-Trigger-After-Swap", @"{""openModalEvent"":""true""}");
        return Task.FromResult<RazorComponentResult>(new RazorComponentResult<DeleteProductPage>(new
        {
            ProductId = productId,
        }));
    }

    public static async Task<RazorComponentResult> HandleAction(
        [FromRoute] Guid productId,
        [FromServices] MyAppDbContext appDbContext,
        [FromBody] Request request,
        HttpContext httpContext)
    {
        var product = await appDbContext.Set<Product>().FindAsync(productId);
        product.DeletedAt = DateTimeOffset.Now;
        product.DeletionReason = request.Reason;
        await appDbContext.SaveChangesAsync();

        httpContext.Response.Headers.Append("HX-Trigger-After-Swap", @$"{{""successEvent"":""The product was deleted successfully"", ""closeModalEvent"":""true""}}");
        return await ListProducts.HandlePage(appDbContext, new ListProducts.Request(), httpContext);
    }
}

The HandlePage method will return the HTML of the modal form and an HX-Trigger response header to trigger an openModalEvent custom event as soon as the client receives the response (we already explained this HTMX feature here). The HandleAction method will soft delete the product and return a successEvent and closeModalEvent custom event. The HTML returned will be the updated list of products. Add the following script in the MainPage.razor file:

<script>
    const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById("modal"))
    htmx.on("openModalEvent", (e) => {
        modal.show()
    })
    htmx.on("closeModalEvent", (e) => {
        modal.hide()
    })
    htmx.on("hidden.bs.modal", () => {
        document.getElementById("modal-dialog").innerHTML = ""
    })
</script>

In this section, we add handlers for the HTMX custom events to show and close the form modal. We also add a handler for the hidden.bs.modal event, which fires when the modal is hidden, to clean up the content. Add the following line to the Products/Endpoints.cs file:

group.MapGet("/{productId:guid}/delete", DeleteProduct.HandlePage);
group.MapPost("/{productId:guid}/delete", DeleteProduct.HandleAction);

As a final step, let's update the HandlePage method in the Product/ListProducts.cs file as follows:

public static async Task<RazorComponentResult> HandlePage(
    [FromServices] MyAppDbContext appDbContext,
    [AsParameters] Request request,
    HttpContext httpContext,
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 10)
{
    var name = request.Name ?? string.Empty;
    var results = await appDbContext.Set<Product>()
        .AsNoTracking().Where(p => p.Name.Contains(name) && p.DeletedAt == null).ToPagedListAsync(page, pageSize);
    var uri = "/products/list".AddQuery(new KeyValuePair<string, StringValues>[]
    {
        new("Name", request.Name),
        new("Page", page.ToString()),
        new("PageSize", pageSize.ToString())
    });
    httpContext.Response.Headers.Append("HX-Push-Url", uri.PathAndQuery);
    return new RazorComponentResult<ListProductsPage>(new { Results = results, Query = uri.Query });
}

Run the application and test the new soft delete feature:

You can find the final code here. Thank you, and happy coding.