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.