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

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

So far, our application does not provide any feedback to the user when a new product is registered or edited, whether the operation is successful, has failed, or is in progress. HTMX provides the mechanism to implement these features in combination with Bootstrap. You can download the starting code from the previous article.

In-Progress Request

During an in-progress request, we will disable the Save Changes button and add a spinner animation. Open the RazorComponents/Form.razor file and make the following changes:

<form hx-post=@HtmxAttributes.Endpoint
      hx-target=@HtmxAttributes.Target
      hx-swap=@HtmxAttributes.Swap
      hx-indicator="#form-spinner"
      hx-disabled-elt="#form-button"
      hx-ext="json-enc">
    @Content
    <button id="form-button" type="submit" class="btn btn-primary">
        <span>Save Changes</span>
        <span id="form-spinner" class="spinner-border spinner-border-sm htmx-indicator" />
    </button>
</form>
@code {
    [Parameter, EditorRequired]
    public RenderFragment? Content { get; set; } = default!;
    [Parameter, EditorRequired]
    public HtmxAttributes HtmxAttributes { get; set; } = default!;
}

To disable the button, we use the hx-disabled-elt attribute. The element specified in this attribute will have the disabled attribute added to it while the request is in progress. For the spinner, we add the Bootstrap component with the class htmx-indicator. The hx-indicator attribute lets us specify the element shown during the request. Run the application to see the results:

Handling Errors

Let's update the Products/RegisterProduct.cs and update the HandleAction method file as follows:

public static async Task<RazorComponentResult> HandleAction(
    [FromServices] MyAppDbContext appDbContext,
    [FromBody] Request request)
{
    var any = await appDbContext.Set<Product>().AsNoTracking().AnyAsync(p => p.Name == request.Name);
    if (any)
    {
        throw new InvalidOperationException("The product already exists");
    }
    var product = new Product()
    {
        ProductId = Guid.NewGuid(),
        Name = request.Name,
        Description = request.Description,
        Price = request.Price,
        Unit = request.Unit,
        IsEnabled = false
    };
    appDbContext.Set<Product>().Add(product);
    await appDbContext.SaveChangesAsync();
    return await ListProducts.HandlePage(appDbContext, new ListProducts.Request(), httpContext);
}

We throw an exception if the product name already exists in the database. To complete the exception handling, create a DefaultExceptionHandler.cs file with the following content:

using Microsoft.AspNetCore.Diagnostics;
using System.Net;

namespace MyApp;

public class DefaultExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;

    public DefaultExceptionHandler(IProblemDetailsService problemDetailsService)
    {
        _problemDetailsService = problemDetailsService;
    }

    public ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is InvalidOperationException ex)
        {
            httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            return _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
            {
                HttpContext = httpContext,
                ProblemDetails =
                {
                    Title = "An error occurred",
                    Detail = ex.Message,
                    Status = (int)HttpStatusCode.BadRequest,
                    Type = "application-error",
                },
                Exception = exception
            });
        }
        httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
        return _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            ProblemDetails =
                {
                    Title = "An error occurred",
                    Detail = exception.Message,
                    Status = (int)HttpStatusCode.InternalServerError,
                    Type = "internal-server-error",
                },
            Exception = exception
        });
    }
}

The class above will handle the exceptions to return the correct response. If an InvalidOperationException is thrown, a BadRequest will be returned; otherwise, an InternalServerError will be returned. Update the Program.cs file as follows to use the exception middleware:

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using MyApp;
using MyApp.Products;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DefaultExceptionHandler>();
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddDbContext<MyAppDbContext>(options => options.UseSqlServer(builder.Configuration["ConnectionString"]));
var app = builder.Build();
app.MapGet("/", () =>
{
    return new RazorComponentResult<MainPage>();
});
app.UseExceptionHandler();
app.RegisterProductEndpoints();
app.Run();

With the current code, if a duplicate product is detected during registration, the application will get stuck without providing any feedback to the user. To solve this problem, we can use HTMX events.

Htmx provides an extensive events system that can be used to modify and enhance behavior.

The specific event we will use is the htmx:responseError event. This event is triggered when an HTTP error response occurs. To show the error, we will use the toast component from Bootstrap. In the MainPage.razor file, include the following fragment after the main tag:

<div class="toast-container position-fixed top-0 end-0 p-3">
    <div id=error-toast class="toast align-items-center text-bg-danger border-0">
        <div class="d-flex">
            <div class="toast-body">
            </div>
            <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
        </div>
    </div>
</div>

To handle the error, add the following script section:

<script>
    ; (function () {
        const errorToastElement = document.getElementById("error-toast")
        const errorToastBody = errorToastElement.querySelector(".toast-body")
        const errorToast = bootstrap.Toast.getOrCreateInstance(errorToastElement)
        htmx.on("htmx:responseError", (e) => {
            errorToastBody.innerText = JSON.parse(e.detail.xhr.response).detail
            errorToast.show()
        })
    })()
</script>

Run the application and register a duplicated product to see the results:

Success Message

HTMX includes several useful headers in the requests. At the same time, we can include headers in the response to change the behavior on the client side. The most useful could be HX-Trigger response headers to trigger events as soon as the response is received. Navigate to the Products/RegisterProduct.cs file and update the content as follows:

public static async Task<RazorComponentResult> HandleAction(
    [FromServices] MyAppDbContext appDbContext,
    [FromBody] Request request,
    HttpContext httpContext)
{
    var any = await appDbContext.Set<Product>().AsNoTracking().AnyAsync(p => p.Name == request.Name);
    if (any)
    {
        throw new InvalidOperationException("The product already exists");
    }
    var product = new Product()
    {
        ProductId = Guid.NewGuid(),
        Name = request.Name,
        Description = request.Description,
        Price = request.Price,
        Unit = request.Unit,
        IsEnabled = false
    };
    appDbContext.Set<Product>().Add(product);
    await appDbContext.SaveChangesAsync();
    httpContext.Response.Headers.Append("HX-Trigger-After-Swap", @$"{{""successEvent"":""The product was register successfully""}}");
    return await ListProducts.HandlePage(appDbContext, new ListProducts.Request(), httpContext);
}

With the HX-Trigger-After-Swap header, we will trigger the custom successEvent event on the client side (To understand the lifecycle of the HTMX request, check here.). To show the error, we will use another toast, go to MainPage.razor file, and include the following fragment after the main tag:

<div class="toast-container position-fixed top-0 end-0 p-3">
    <div id=error-toast class="toast align-items-center text-bg-danger border-0">
        <div class="d-flex">
            <div class="toast-body">
            </div>
            <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
        </div>
    </div>
</div>

To listen to the event, add the following script section:

<script>
    ; (function () {
        const successToastElement = document.getElementById("success-toast")
        const successToastBody = successToastElement.querySelector(".toast-body")
        const sucessToast = bootstrap.Toast.getOrCreateInstance(successToastElement)
        htmx.on("successEvent", (e) => {
            successToastBody.innerText = e.detail.value
            sucessToast.show()
        })
    })()
</script>

Run the application and register a new product to see the results:

Push URL to the Browser

So far, we have noticed that the URL in the browser always stays the same, which makes sense because we are just rendering parts of the HTML and not the entire page. So, how do we push a URL to the browser?

The hx-push-url attribute allows you to push a URL into the browser location history. This creates a new history entry, allowing navigation with the browser’s back and forward buttons. htmx snapshots the current DOM and saves it into its history cache, and restores from this cache on navigation.

The change is quite easy. Navigate to RazorComponents/ActionButton.razor file and update the HTML section as follows:

<button type="button"
        hx-get=@HtmxAttributes.Endpoint
        hx-target=@HtmxAttributes.Target
        hx-swap=@HtmxAttributes.Swap
        hx-push-url="true"
        class="@Icon btn btn-primary">
    New
</button>

The same change in the RazorComponents/ActionLink.razor file:

<a class="icon-link"
   href="#"
   hx-get=@HtmxAttributes.Endpoint
   hx-target=@HtmxAttributes.Target
   hx-push-url="true"
   hx-swap=@HtmxAttributes.Swap>
    <span class=@Icon></span>
</a>

For the final change, we will use the HX-Push-Url response header. Navigate to the Products/ListProducts.cs file and update the HandlePage method as follows:

public static async Task<RazorComponentResult> HandlePage(
    [FromServices] MyAppDbContext appDbContext,
    [AsParameters] Request request,
    HttpContext httpContext)
{
    var name = request.Name ?? string.Empty;
    var results = await appDbContext.Set<Product>().AsNoTracking().Where(p => p.Name.Contains(name)).ToListAsync();
    httpContext.Response.Headers.Append("HX-Push-Url", $"/products/list");
    return new RazorComponentResult<ListProductsPage>(new { Results = results });
}

Run the application to see the browser's URL change according to our navigation. You can find the final code here. Thank you, and happy coding.