Building Your First App with HTMX and .NET - Part II

By FoxLearn 2/7/2025 8:50:43 AM   50
In this episode, we’ll enhance our Articles app by adding an article editing feature. Users will be able to edit article details with a clean and responsive interface.

Before we dive into the feature implementation, we’ll refactor some parts of the code for better maintainability by creating reusable Razor components.

Refactoring the ListArticlesPage.razor

We begin by refactoring the ListArticlesPage.razor file. First, create the RazorComponents/DataTable.razor file:

@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 component will help us display a list of articles. Create the RazorComponents/HtmxAttributes.cs file:

namespace BlogApp.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 HtmxAttributes class stores HTMX attributes such as the target and swap options to pass between components. Next, create the RazorComponents/SearchFilter.razor component:

<input type="search"
       class="form-control"
       name=@Property
       hx-trigger="input changed delay:500ms, search"
       [email protected]
       [email protected]
       [email protected]
       [email protected]
       @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 SearchFilter component allows for a search field with HTMX support. Next, create the RazorComponents/ActionButton.razor file:

<button type="button"
        [email protected]
        [email protected]
        [email protected]
        class="@Icon btn btn-primary">
    New Article
</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;
}

Finally, create the RazorComponents/Section.razor file:

<div class="card mb-4">
    <div class="card-body">
        @Content
    </div>
</div>
@code {
    [Parameter, EditorRequired]
    public RenderFragment? Content { get; set; } = default!;
}

With these components in place, the ListArticlesPage.razor file can be refactored as follows:

@using BlogApp.RazorComponents;
<h4>List Articles</h4>
<nav class="navbar hstack gap-3 justify-content-end">
    <ActionButton Label="New Article"
                  Icon="bi bi-plus-lg"
                  HtmxAttributes=@(new HtmxAttributes("/articles/register", "#main", "innerHTML")) />
</nav>
<Section>
    <Content>
        <div class="row mb-4">
            <div class="col">
                <SearchFilter Property="Title"
                              placeholder="Enter title"
                              HtmxAttributes=@(new HtmxAttributes("/articles/list", "#results", "OuterHTML", "#results")) />
            </div>
        </div>
        <div id="results">
            <DataTable Items=@Articles
                       Context="item">
                <TableHeader>
                    <th>#</th>
                    <th>Title</th>
                    <th>Author</th>
                    <th>Created At</th>
                    <th>Is Published</th>
                </TableHeader>
                <RowTemplate>
                    <td>@item.ArticleId</td>
                    <td>@item.Title</td>
                    <td>@item.Author</td>
                    <td>@item.CreatedAt</td>
                    <td>@item.IsPublished</td>
                </RowTemplate>
            </DataTable>
        </div>
    </Content>
</Section>
@code {
    [Parameter]
    public List<Article> Articles { get; set; } = default!;
}

To display icons, include the following link tag in your MainPage.razor file:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" />

Create the RegisterArticlePage.razor

The registration page allows users to create a new article. First, we need to create several new reusable components.

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/Form.razor

<form [email protected]
      [email protected]
      [email protected]
      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!;
}

Now, using these components, the RegisterArticlePage.razor will look like this:

@using BlogApp.RazorComponents;
<h4>Register Article</h4>
<Section>
    <Content>
        <Form HtmxAttributes=@(new HtmxAttributes("/articles/register","#main", "innerHTML"))>
            <Content>
                <div class="row mb-4">
                    <div class="col-6">
                        <TextInput Property="Title"
                                   Label="Title"
                                   placeholder="Enter title"
                                   maxlength=200
                                   required />
                    </div>
                    <div class="col-6">
                        <TextInput Property="Author"
                                   Label="Author"
                                   placeholder="Enter author's name"
                                   required />
                    </div>
                </div>
                <div class="row mb-4">
                    <div class="col">
                        <TextAreaInput Property="Content"
                                       Label="Content"
                                       rows="5"
                                       placeholder="Enter content"
                                       maxlength=4000
                                       required />
                    </div>
                </div>
            </Content>
        </Form>
    </Content>
</Section>
@code { }

Editing an Article

To edit an article, we will create a handler for the EditArticle functionality.

Articles/EditArticle.cs

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace BlogApp.Articles;

public static class EditArticle
{
    public class Request
    {
        public string? Content { get; set; }
        public string? Title { get; set; }
        public bool IsPublished { get; set; }
    }

    public static async Task<RazorComponentResult> HandlePage([FromRoute] Guid articleId, [FromServices] BlogAppDbContext appDbContext)
    {
        var article = await appDbContext.Set<Article>().AsNoTracking().FirstAsync(a => a.ArticleId == articleId);
        return new RazorComponentResult<EditArticlePage>(new { Article = article });
    }

    public static async Task<RazorComponentResult> HandleAction([FromRoute] Guid articleId, [FromServices] BlogAppDbContext appDbContext, [FromBody] Request request)
    {
        var article = await appDbContext.Set<Article>().FindAsync(articleId);
        article.Content = request.Content;
        article.Title = request.Title;
        article.IsPublished = request.IsPublished;
        await appDbContext.SaveChangesAsync();
        return await ListArticles.HandlePage(appDbContext, new ListArticles.Request());
    }
}

The HandlePage method renders the edit form, while the HandleAction method updates the article after submission.

Now, update the Endpoints.cs file:

namespace BlogApp.Articles;
public static class Endpoints
{
    public static void RegisterArticleEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/articles");
        group.MapGet("/list", ListArticles.HandlePage);
        group.MapGet("/register", RegisterArticle.HandlePage);
        group.MapPost("/register", RegisterArticle.HandleAction);
        group.MapGet("/{articleId:guid}/edit", EditArticle.HandlePage);
        group.MapPost("/{articleId:guid}/edit", EditArticle.HandleAction);
    }
}

Now, create an ActionLink component to show the edit button:

<a class="icon-link"
   href="#"
   [email protected]
   [email protected]
   [email protected]>
    <span class=@Icon></span>
</a>
@code {
    [Parameter, EditorRequired]
    public string Icon { get; set; } = default!;
    [Parameter, EditorRequired]
    public HtmxAttributes HtmxAttributes { get; set; } = default!;
}

Finally, update the ListArticlesPage.razor to add the edit functionality:

<DataTable Items=@Articles
           Context="item">
    <TableHeader>
        <th></th>
        <th>#</th>
        <th>Title</th>
        <th>Author</th>
        <th>Created At</th>
        <th>Is Published</th>
    </TableHeader>
    <RowTemplate>
        <td>
            <div class="hstack gap-1">
                <ActionLink Icon="bi bi-pencil" 
                            HtmxAttributes=@(new HtmxAttributes($"/articles/{item.ArticleId}/edit", "#main", "innerHTML")) />
            </div>
        </td>
        <td>@item.ArticleId</td>
        <td>@item.Title</td>
        <td>@item.Author</td>
        <td>@item.CreatedAt</td>
        <td>@item.IsPublished</td>
    </RowTemplate>
</DataTable>

The Edit Article Page

The EditArticlePage.razor component will look like this:

@using BlogApp.RazorComponents;
<h4>Edit Article</h4>
<Section>
    <Content>
        <Form HtmxAttributes=@(new HtmxAttributes($"/articles/{Article.ArticleId}/edit","#main", "innerHTML"))>
            <Content>
                <div class="row mb-4">
                    <div class="col-6">
                        <TextInput Property="Title"
                                   Label="Title"
                                   placeholder="Enter title"
                                   maxlength=200
                                   [email protected]
                                   disabled
                                   readonly />
                    </div>
                    <div class="col-6">
                        <TextInput Property="Author"
                                   Label="Author"
                                   [email protected]
                                   disabled
                                   readonly />
                    </div>
                </div>
                <div class="row mb-4">
                    <div class="col">
                        <TextAreaInput Property="Content"
                                       Label="Content"
                                       rows="5"
                                       [email protected]
                                       placeholder="Enter content"
                                       maxlength=4000 />
                    </div>
                </div>
                <div class="row mb-4">
                    <div class="col">
                        <label for="IsPublished">Is Published</label>
                        <input type="checkbox" id="IsPublished" name="IsPublished" checked="@Article.IsPublished" />
                    </div>
                </div>
            </Content>
        </Form>
    </Content>
</Section>
@code { }

This page will allow users to edit the title, content, and publication status of an article.