A new episode in our HTMX and .NET series has arrived. This time, we will be adding an edit feature to our app. Before diving into that, we'll refactor our code by creating several Razor components to enhance code reusability. You can download the starting code from the previous article.
ListProductsPage.razor
So, let's start by refactoring the ListProductsPage.razor
file. Create the RazorComponents/DataTable.razor
file with the following content:
@typeparam TItem
<div class="table-responsive">
<table class="table">
<thead class="table-light">
<tr>@TableHeader</tr>
</thead>
<tbody>
@if (Items is not null && Items.Any())
{
@foreach (var item in Items)
{
<tr>
@RowTemplate(item)
</tr>
}
}
</tbody>
</table>
</div>
@code {
[Parameter, EditorRequired]
public IEnumerable<TItem> Items { get; set; } = default!;
[Parameter, EditorRequired]
public RenderFragment<TItem> RowTemplate { get; set; } = default!;
[Parameter, EditorRequired]
public RenderFragment? TableHeader { get; set; } = default!;
}
This templated component will display a list of items. Create the RazorComponents/HtmxAttributes.cs
file with the following content:
namespace MyApp.RazorComponents;
public class HtmxAttributes
{
public string Target { get; set; } = default!;
public string Swap { get; set; } = "innerHTML";
public string Endpoint { get; set; } = default!;
public string Select { get; set; } = default!;
public HtmxAttributes()
{
}
public HtmxAttributes(string endpoint, string target)
{
Endpoint = endpoint;
Target = target;
}
public HtmxAttributes(string endpoint, string target, string swap) : this(endpoint, target)
{
Swap = swap;
}
public HtmxAttributes(string endpoint, string target, string swap, string select) : this(endpoint, target, swap)
{
Select = select;
}
}
The class HtmxAttributes
will serve as a parameter for components with HTMX tags. Create the RazorComponents/SearchFilter.razor
as follows:
<input type="search"
class="form-control"
name=@Property
hx-trigger="input changed delay:500ms, search"
hx-get=@HtmxAttributes.Endpoint
hx-swap=@HtmxAttributes.Swap
hx-target=@HtmxAttributes.Target
hx-select=@HtmxAttributes.Select
@attributes="Attributes" />
@code {
[Parameter, EditorRequired]
public string Property { get; set; } = default!;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? Attributes { get; set; }
[Parameter, EditorRequired]
public HtmxAttributes HtmxAttributes { get; set; } = default!;
}
The Attributes
parameter is used to capture unexpected parameters in our component. Create the RazorComponents/ActionButton.razor
file as follows:
<button type="button"
hx-get=@HtmxAttributes.Endpoint
hx-target=@HtmxAttributes.Target
hx-swap=@HtmxAttributes.Swap
class="@Icon btn btn-primary">
New
</button>
@code {
[Parameter, EditorRequired]
public string Label { get; set; } = default!;
[Parameter, EditorRequired]
public HtmxAttributes HtmxAttributes { get; set; } = default!;
[Parameter]
public string Icon { get; set; } = string.Empty;
}
Create the RazorComponents/Section.razor
file with the following content:
<div class="card mb-4">
<div class="card-body">
@Content
</div>
</div>
@code {
[Parameter, EditorRequired]
public RenderFragment? Content { get; set; } = default!;
}
The ListProductsPage.razor
file using the new components will look like this:
@using MyApp.RazorComponents;
<h4>List Products</h4>
<nav class="navbar hstack gap-3 justify-content-end">
<ActionButton Label="New"
Icon="bi bi-plus-lg"
HtmxAttributes=@(new HtmxAttributes("/products/register", "#main", "innerHTML")) />
</nav>
<Section>
<Content>
<div class="row mb-4">
<div class="col">
<SearchFilter Property="Name"
placeholder="Enter name"
HtmxAttributes=@(new HtmxAttributes("/products/list", "#results", "OuterHTML", "#results")) />
</div>
</div>
<div id="results">
<DataTable Items=@Results
Context="item">
<TableHeader>
<th>#</th>
<th>Name</th>
<th>Unit</th>
<th>Price</th>
<th>Is Enabled</th>
</TableHeader>
<RowTemplate>
<td>@item.ProductId</td>
<td>@item.Name</td>
<td>@item.Unit</td>
<td>@item.Price</td>
<td>@item.IsEnabled</td>
</RowTemplate>
</DataTable>
</div>
</Content>
</Section>
@code {
[Parameter]
public List<Product> Results { get; set; } = default!;
}
To use the icon bi bi-plus-lg
, add the following link
tag in the MainPage.razor
file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
RegisterProductPage.razor
Let's do the same exercise with the file RegisterProductPage.razor
. The new components will be:
RazorComponents/TextInput.razor
<div class="form-group">
<label for=@Property class="form-label">@Label</label>
<input type="text"
class="form-control"
id=@Property
name=@Property
@attributes="Attributes" />
</div>
@code {
[Parameter, EditorRequired]
public string Property { get; set; } = default!;
[Parameter, EditorRequired]
public string Label { get; set; } = default!;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? Attributes { get; set; }
}
RazorComponents/TextAreaInput.razor
<div class="form-group">
<label for=@Property class="form-label">@Label</label>
<textarea class="form-control"
id=@Property
name=@Property
@attributes="Attributes" />
</div>
@code {
[Parameter, EditorRequired]
public string Property { get; set; } = default!;
[Parameter, EditorRequired]
public string Label { get; set; } = default!;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? Attributes { get; set; }
}
RazorComponents/SelectInput.razor
@typeparam TKey where TKey : notnull
<div class="form-group">
<label for=@Property class="form-label">@Label</label>
<select class="form-select"
name=@Property
id=@Property
@attributes="Attributes">
@foreach (var item in Source)
{
@if (Value != null && Value.Equals(item.Key))
{
<option value=@item.Key selected>@item.Value</option>
}
else
{
<option value=@item.Key>@item.Value</option>
}
}
</select>
</div>
@code {
[Parameter, EditorRequired]
public string Property { get; set; } = default!;
[Parameter, EditorRequired]
public string Label { get; set; } = default!;
[Parameter]
public TKey Value { get; set; } = default!;
[Parameter]
public Dictionary<TKey, string> Source { get; set; } = new Dictionary<TKey, string>();
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? Attributes { get; set; }
}
RazorComponents/NumericInput.razor
<div class="form-group">
<label for=@Property class="form-label">@Label</label>
@if (string.IsNullOrEmpty(Prefix) && string.IsNullOrEmpty(Sufix))
{
<input type="number"
class="form-control"
id=@Property
name=@Property
@attributes="Attributes" />
}
else
{
<div class="input-group">
@if (!string.IsNullOrEmpty(Prefix))
{
<span class="input-group-text">@Prefix</span>
}
<input type="number"
class="form-control"
id=@Property
name=@Property
@attributes="Attributes" />
@if (!string.IsNullOrEmpty(Sufix))
{
<span class="input-group-text">@Sufix</span>
}
</div>
}
</div>
@code {
[Parameter, EditorRequired]
public string Property { get; set; } = default!;
[Parameter, EditorRequired]
public string Label { get; set; } = default!;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? Attributes { get; set; }
[Parameter]
public string Prefix { get; set; } = default!;
[Parameter]
public string Sufix { get; set; } = default!;
}
RazorComponents/Form.razor
<form hx-post=@HtmxAttributes.Endpoint
hx-target=@HtmxAttributes.Target
hx-swap=@HtmxAttributes.Swap
hx-ext="json-enc">
@Content
<button type="submit" class="btn btn-primary">
<span>Save Changes</span>
</button>
</form>
@code {
[Parameter, EditorRequired]
public RenderFragment? Content { get; set; } = default!;
[Parameter, EditorRequired]
public HtmxAttributes HtmxAttributes { get; set; } = default!;
}
The RegisterProductPage.razor
file using the new components will look like this:
@using MyApp.RazorComponents;
<h4>Register Product</h4>
<Section>
<Content>
<Form HtmxAttributes=@(new HtmxAttributes("/products/register","#main", "innerHTML"))>
<Content>
<div class="row mb-4">
<div class="col-4">
<TextInput Property="Name"
Label="Name"
placeholder="Enter name"
maxlength=100
required />
</div>
<div class="col-4">
<NumericInput Property="Price"
Label="Price"
Prefix="$"
step="0.01"
min="0.01"
value="0"
required />
</div>
<div class="col-4">
<SelectInput Property="Unit"
Label="Unit"
required
Source=@(new Dictionary<string, string>(){{"UN","Unit"},{"KG","Kilogram"}}) />
</div>
</div>
<div class="row mb-4">
<div class="col">
<TextAreaInput Property="Description"
Label="Description"
rows="5"
placeholder="Enter description"
maxlength=500 />
</div>
</div>
</Content>
</Form>
</Content>
</Section>
@code {
}
Edit Product
Create a Products/EditProduct.cs
file with:
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Products;
public static class EditProduct
{
public class Request
{
public string? Description { get; set; }
public decimal Price { get; set; }
public Unit Unit { get; set; }
}
public static async Task<RazorComponentResult> HandlePage([FromRoute] Guid productId, [FromServices] MyAppDbContext appDbContext)
{
var product = await appDbContext.Set<Product>().AsNoTracking().FirstAsync(p => p.ProductId == productId);
return new RazorComponentResult<EditProductPage>(new { Product = product });
}
public static async Task<RazorComponentResult> HandleAction([FromRoute] Guid productId, [FromServices] MyAppDbContext appDbContext, [FromBody] Request request)
{
var product = await appDbContext.Set<Product>().FindAsync(productId);
product.Description = request.Description;
product.Price = request.Price;
product.Unit = request.Unit;
await appDbContext.SaveChangesAsync();
return await ListProducts.HandlePage(appDbContext, new ListProducts.Request());
}
}
The HandlePage
method will receive the request to render the edit form, and the HandleAction
method will process the request from the form. Update the Endpoints.cs
file as follows:
namespace MyApp.Products;
public static class Endpoints
{
public static void RegisterProductEndpoints(this WebApplication app)
{
var group = app.MapGroup("/products");
group.MapGet("/list", ListProducts.HandlePage);
group.MapGet("/register", RegisterProduct.HandlePage);
group.MapPost("/register", RegisterProduct.HandleAction);
group.MapGet("/{productId:guid}/edit", EditProduct.HandlePage);
group.MapPost("/{productId:guid}/edit", EditProduct.HandleAction);
}
}
Let's create a new component, RazorComponents.ActionLink.razor
:
<a class="icon-link"
href="#"
hx-get=@HtmxAttributes.Endpoint
hx-target=@HtmxAttributes.Target
hx-swap=@HtmxAttributes.Swap>
<span class=@Icon></span>
</a>
@code {
[Parameter, EditorRequired]
public string Icon { get; set; } = default!;
[Parameter, EditorRequired]
public HtmxAttributes HtmxAttributes { get; set; } = default!;
}
Modify the Products/ListProductsPage.razor
file by changing the <DataTable>
element with:
<DataTable Items=@Results
Context="item">
<TableHeader>
<th></th>
<th>#</th>
<th>Name</th>
<th>Unit</th>
<th>Price</th>
<th>Is Enabled</th>
</TableHeader>
<RowTemplate>
<td>
<div class="hstack gap-1">
<ActionLink Icon="bi bi-pencil"
HtmxAttributes=@(new HtmxAttributes($"/products/{item.ProductId}/edit", "#main", "innerHTML")) />
</div>
</td>
<td>@item.ProductId</td>
<td>@item.Name</td>
<td>@item.Unit</td>
<td>@item.Price</td>
<td>@item.IsEnabled</td>
</RowTemplate>
</DataTable>
The ActionLink
component will render the Products/EditProductPage.razor
component detailed below:
@using MyApp.RazorComponents;
<h4>Edit Product</h4>
<Section>
<Content>
<Form HtmxAttributes=@(new HtmxAttributes($"/products/{Product.ProductId}/edit","#main", "innerHTML"))>
<Content>
<div class="row mb-4">
<div class="col-4">
<TextInput Property="Name"
Label="Name"
placeholder="Enter name"
maxlength=100
value=@Product.Name
disabled
readonly />
</div>
<div class="col-4">
<NumericInput Property="Price"
Label="Price"
Prefix="$"
step="0.01"
min="0.01"
value=@Product.Price
required />
</div>
<div class="col-4">
<SelectInput Property="Unit"
Label="Unit"
required
Value=@Product.Unit.ToString()
Source=@(new Dictionary<string, string>(){{"UN","Unit"},{"KG","Kilogram"}}) />
</div>
</div>
<div class="row mb-4">
<div class="col">
<TextAreaInput Property="Description"
Label="Description"
rows="5"
value=@Product.Description
placeholder="Enter description"
maxlength=500 />
</div>
</div>
</Content>
</Form>
</Content>
</Section>
@code {
[Parameter, EditorRequired]
public Product Product { get; set; } = default!;
}
The main difference with the RegisterProductPage.razor
component is that we have a Product
parameter to set the current values in the form. Additionally, the Name
field is not editable. Run the application:
You can find the final code here. Thank you, and happy coding.