#
Lab 5: Dynamic Forms + Long-Running UX (Polling)
#
Overview
This lab covers two high-value scenarios that traditionally require significant JavaScript but can be elegantly solved with htmx:
- Dynamic UI Composition: Add/remove form rows, dependent dropdowns
- Long-Running Operations: Progress updates via polling without WebSockets
These patterns appear constantly in real applications—order line items, tag management, category filters, background job status, file uploads with progress. By the end of this lab, you'll have reusable blueprints for all of them.
#
The Key Insight
Both patterns share a common theme: the server controls the UI state.
#
Lab Outcomes
By the end of Lab 5, you will be able to:
#
Prerequisites
Before starting this lab, ensure you have:
- Completed Lab 4 with all verifications passing
- Production-livable conventions in place (checkpoint complete)
- Working CRUD operations for Tasks
- Fragment helpers (
IsHtmx()andFragment()) ready
#
Pattern 1: Dynamic Form Rows (Add/Remove Tags) (15–20 minutes)
This pattern lets users add and remove items in a sub-collection—like tags on a task, line items on an order, or attendees on an event.
#
1.1 The Design
User Flow:
- User sees a task form with a "Tags" section
- User clicks "Add Tag" → new empty tag input appears
- User can remove any tag by clicking its "Remove" button (client-side, no server round-trip)
- On form submit, all tags are collected and saved
Architecture:
- Tags are rendered as a list of inputs inside a
#tags-container - "Add Tag" button fetches a new tag row fragment from server
- Each tag row has a "Remove" button that removes itself client-side using htmx's
hx-onattribute - Tags use simple name binding (
Input.Tags) - ASP.NET Core handles multiple values automatically
#
1.2 Update the TaskItem Model
First, add Tag support to the TaskItem model:
File: Models/TaskItem.cs
namespace RazorPagesHtmxWorkshop.Models;
public class TaskItem
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool IsDone { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public string? Category { get; set; }
public string? Subcategory { get; set; }
public List<string> Tags { get; set; } = new();
}
#
1.3 Update the Input Model
Extend NewTaskInput to support tags:
File: Pages/Tasks/Index.cshtml.cs (update NewTaskInput class)
public class NewTaskInput
{
[Required(ErrorMessage = "Title is required.")]
[StringLength(60, MinimumLength = 3, ErrorMessage = "Title must be 3–60 characters.")]
public string Title { get; set; } = "";
/// <summary>
/// Tags for the task. Each tag is a simple string.
/// Model binding automatically collects multiple inputs with the same name.
/// </summary>
public List<string> Tags { get; set; } = new();
/// <summary>
/// Category for the task (first-level selection).
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Subcategory for the task (depends on Category).
/// </summary>
public string? Subcategory { get; set; }
}
#
1.4 Create the Tag Row Fragment
This fragment renders a single tag input with a client-side remove button:
File: Pages/Tasks/Partials/_TagRow.cshtml
@model (int Index, string Value)
@*
Tag Row Fragment
================
Purpose: Single tag input with remove button
Model: (int Index, string Value) - tuple with index and current value
Design notes:
- Uses simple name="Input.Tags" for model binding (no indexes needed!)
- Remove button uses hx-on:click for client-side removal (no server round-trip)
- ASP.NET Core model binder handles multiple inputs with same name
Swap strategy:
- Added via hx-swap="beforeend" into #tags-list
- Removed via client-side JavaScript using hx-on:click
*@
<div class="tag-row input-group mb-2">
<input type="text"
class="form-control"
name="Input.Tags"
value="@Model.Value"
placeholder="Enter tag..." />
<button type="button"
class="btn btn-outline-danger"
hx-on:click="this.closest('.tag-row').remove()"
title="Remove tag">
<span aria-hidden="true">×</span>
<span class="visually-hidden">Remove tag</span>
</button>
</div>
Key Points:
- Simplified binding: All tag inputs use the same
name="Input.Tags", and ASP.NET Core automatically binds them to a List - Client-side removal: Uses
hx-on:click="this.closest('.tag-row').remove()"to remove the row without a server call - No index tracking needed: The server doesn't need to manage indexes
#
1.5 Create the Tags Container Fragment
This fragment wraps all tag rows and includes the "Add Tag" button:
File: Pages/Tasks/Partials/_TagsContainer.cshtml
@model List<string>
@*
Tags Container Fragment
=======================
Purpose: Container for all tag rows + Add button
Model: List<string> - current tags
Design notes:
- Container has stable ID #tags-container
- Add button appends new rows via hx-swap="beforeend"
- No need to track indexes - model binding handles it
*@
<div id="tags-container" class="mb-3">
<label class="form-label">Tags</label>
<div id="tags-list">
@for (var i = 0; i < Model.Count; i++)
{
<partial name="Partials/_TagRow" model="(i, Model[i])" />
}
</div>
<button type="button"
class="btn btn-sm btn-outline-secondary mt-2"
hx-get="?handler=AddTag"
hx-target="#tags-list"
hx-swap="beforeend">
<span aria-hidden="true">+</span> Add Tag
</button>
<div class="form-text">Add tags to categorize your task.</div>
</div>
#
1.6 Add the Tag Handler
File: Pages/Tasks/Index.cshtml.cs (add to #region Dynamic Tags)
#region Dynamic Tags
/// <summary>
/// Returns a new tag row fragment.
/// </summary>
public IActionResult OnGetAddTag()
{
// Return a new empty tag row (index doesn't matter for binding)
return Fragment("Partials/_TagRow", (0, ""));
}
/// <summary>
/// Handles tag removal (not actually used - removal is client-side).
/// Kept for potential server-side validation or tracking.
/// </summary>
public IActionResult OnGetRemoveTag()
{
return new EmptyResult();
}
#endregion
#
1.7 Update InMemoryTaskStore
Update the Add method to accept tags, category, and subcategory:
File: Data/InMemoryTaskStore.cs
public static TaskItem Add(string title, string? category = null, string? subcategory = null, List<string>? tags = null)
{
var item = new TaskItem
{
Id = _nextId++,
Title = title.Trim(),
IsDone = false,
CreatedUtc = DateTime.UtcNow,
Category = category,
Subcategory = subcategory,
Tags = tags ?? new()
};
_tasks.Add(item);
return item;
}
#
1.8 Update OnPostCreate to Handle Tags
File: Pages/Tasks/Index.cshtml.cs (update OnPostCreate method)
public IActionResult OnPostCreate()
{
// Clean up empty tags before validation
Input.Tags = Input.Tags
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())
.ToList();
if (!TryValidateModel(Input, nameof(Input)))
{
Tasks = InMemoryTaskStore.All();
if (IsHtmx())
{
Response.Headers["HX-Retarget"] = "#task-form";
Response.Headers["HX-Reswap"] = "outerHTML";
return Fragment("Partials/_TaskForm", this);
}
return Page();
}
if (Input.Title.Trim().Equals("boom", StringComparison.OrdinalIgnoreCase))
{
if (IsHtmx())
{
Response.Headers["HX-Retarget"] = "#messages";
Response.Headers["HX-Reswap"] = "innerHTML";
return Fragment("Partials/_Error",
"Simulated server error. Try a different title.");
}
throw new InvalidOperationException("Simulated server error.");
}
// Add task with tags, category and subcategory
var task = InMemoryTaskStore.Add(Input.Title, Input.Category, Input.Subcategory, Input.Tags);
// Log details
if (Input.Tags.Count > 0)
{
Console.WriteLine($"Task {task.Id} created with tags: {string.Join(", ", Input.Tags)}");
}
if (!string.IsNullOrWhiteSpace(Input.Category))
{
Console.WriteLine($"Task {task.Id} category: {Input.Category} / {Input.Subcategory}");
}
if (IsHtmx())
{
var tagCount = Input.Tags.Count;
FlashMessage = tagCount > 0
? $"Task added with {tagCount} tag(s)!"
: "Task added successfully!";
// Reset input including tags for the form refresh
Input = new NewTaskInput();
Response.Headers["HX-Trigger"] = "showMessage,clearForm";
CurrentPage = Math.Max(1, PageNum);
PageSize = Math.Clamp(Size, 1, 50);
var all = InMemoryTaskStore.All();
if (!string.IsNullOrWhiteSpace(Q))
{
all = all
.Where(t => t.Title.Contains(Q, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var total = all.Count;
var items = all
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
var vm = new TaskListVm
{
Items = items,
Page = CurrentPage,
PageSize = PageSize,
Total = total,
Query = Q
};
return Fragment("Partials/_TaskList", vm);
}
FlashMessage = "Task added.";
return RedirectToPage();
}
#
Pattern 2: Dependent Dropdowns (Category → Subcategory) (10–15 minutes)
Dependent dropdowns (cascading selects) update one dropdown based on another's selection—like Category → Subcategory, Country → City, or Make → Model.
#
2.1 The Design
User Flow:
- User selects a Category from the first dropdown
- Subcategory dropdown updates with relevant options
- User selects a Subcategory
- Form can be submitted with both values
Architecture:
- Category dropdown has
hx-getthat fetches subcategory options on change - Subcategory dropdown is wrapped in a swappable container (
#subcategory-container) - Server returns the subcategory
<select>fragment
#
2.2 Create Sample Data
For this example, we'll use simple in-memory data:
File: Data/CategoryData.cs (create new file)
namespace RazorPagesHtmxWorkshop.Data;
/// <summary>
/// Sample data for demonstrating dependent dropdowns.
/// Category → Subcategory cascading selection.
/// </summary>
public static class CategoryData
{
private static readonly Dictionary<string, List<string>> _subcategories = new()
{
["Work"] = new() { "Meeting", "Report", "Email", "Review" },
["Personal"] = new() { "Shopping", "Exercise", "Reading", "Travel" },
["Home"] = new() { "Cleaning", "Repairs", "Gardening", "Cooking" },
["Learning"] = new() { "Course", "Tutorial", "Practice", "Research" }
};
public static IReadOnlyList<string> GetCategories() =>
_subcategories.Keys.ToList();
public static IReadOnlyList<string> GetSubcategories(string? category) =>
string.IsNullOrWhiteSpace(category) || !_subcategories.ContainsKey(category)
? Array.Empty<string>()
: _subcategories[category];
}
#
2.3 Create the Subcategory Fragment
File: Pages/Tasks/Partials/_SubcategorySelect.cshtml
@model (IReadOnlyList<string> Options, string? Selected)
@*
Subcategory Select Fragment
===========================
Purpose: Dropdown for subcategory selection
Model: (Options list, Selected value)
Design notes:
- Wrapper div has stable ID for swapping
- Select is disabled when no options available
- Uses Razor syntax for disabled and selected attributes
*@
<div id="subcategory-container">
<select class="form-select"
name="Input.Subcategory"
id="subcategory"
disabled="@(Model.Options.Count == 0)">
@if (Model.Options.Count == 0)
{
<option value="">Select a category first</option>
}
else
{
<option value="">Select subcategory...</option>
@foreach (var option in Model.Options)
{
<option value="@option" selected="@(option == Model.Selected)">@option</option>
}
}
</select>
</div>
Key Fix:
- Uses
disabled="@(Model.Options.Count == 0)"instead of the old conditional string approach - Uses
selected="@(option == Model.Selected)"for proper boolean attribute binding
#
2.4 Add the Subcategory Handler
File: Pages/Tasks/Index.cshtml.cs (add to #region Dependent Dropdowns)
#region Dependent Dropdowns
/// <summary>
/// Returns the subcategory dropdown options based on selected category.
/// Called when category dropdown changes.
/// </summary>
public IActionResult OnGetSubcategories([FromQuery(Name = "Input.Category")] string? category)
{
var subcategories = CategoryData.GetSubcategories(category);
return Fragment("Partials/_SubcategorySelect", (subcategories, (string?)null));
}
#endregion
Important: Uses [FromQuery(Name = "Input.Category")] to bind the correct query parameter.
#
2.5 Add Category/Subcategory to the Form
File: Pages/Tasks/Partials/_TaskForm.cshtml (add before Tags section)
@* Category and Subcategory dropdowns *@
<div class="row">
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label" for="category">Category</label>
<select class="form-select"
name="Input.Category"
id="category"
asp-for="Input.Category"
hx-get="?handler=Subcategories"
hx-target="#subcategory-container"
hx-swap="outerHTML">
<option value="">Select category...</option>
@foreach (var cat in RazorPagesHtmxWorkshop.Data.CategoryData.GetCategories())
{
<option value="@cat">@cat</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="subcategory">Subcategory</label>
@{
var currentSubcategories = RazorPagesHtmxWorkshop.Data.CategoryData
.GetSubcategories(Model.Input.Category);
}
<partial name="Partials/_SubcategorySelect"
model="(currentSubcategories, Model.Input.Subcategory)" />
</div>
</div>
@* Dynamic Tags section *@
<partial name="Partials/_TagsContainer" model="Model.Input.Tags" />
#
2.6 Test Dependent Dropdowns
- Navigate to
/Tasks - Select a Category (e.g., "Work")
- Observe: Subcategory dropdown updates with relevant options
- Select a different Category
- Observe: Subcategory options change accordingly
- Clear Category (select placeholder)
- Observe: Subcategory becomes disabled
#
Pattern 3: Enhanced Task Details Modal (10 minutes)
Now that we have tags and categories, let's update the details modal to show ALL task information.
#
3.1 Update the TaskDetails Partial
File: Pages/Tasks/Partials/_TaskDetails.cshtml
@using RazorPagesHtmxWorkshop.Models
@model TaskItem?
@*
Task Details Modal
==================
Target ID: #task-modal-container
Purpose: Display detailed information about a single task in a modal
Model: TaskItem? (null if not found)
Lab 5: Now includes Category, Subcategory, Tags, Status, and all metadata
*@
<div class="modal fade" id="task-modal" tabindex="-1" aria-labelledby="taskModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content workshop-card" style="background-color: #f8f9fa;">
<div class="modal-header border-bottom border-light">
<h5 class="modal-title text-dark" id="taskModalLabel">Task Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" style="filter: brightness(0);"></button>
</div>
<div class="modal-body">
@if (Model is null)
{
<div class="text-dark text-center py-4">Task not found.</div>
}
else
{
<div class="vstack gap-4 py-2">
<div>
<label class="form-label text-dark small mb-1">Title</label>
<div class="fw-semibold fs-5 text-dark">@Model.Title</div>
</div>
<div class="row">
<div class="col-6">
<label class="form-label text-dark small mb-1">Category</label>
<div class="fw-medium text-dark">@(Model.Category ?? "None")</div>
</div>
<div class="col-6">
<label class="form-label text-dark small mb-1">Subcategory</label>
<div class="fw-medium text-dark">@(Model.Subcategory ?? "None")</div>
</div>
</div>
<div>
<label class="form-label text-dark small mb-1">Tags</label>
<div>
@if (Model.Tags.Any())
{
@foreach (var tag in Model.Tags)
{
<span class="badge rounded-pill border border-secondary text-dark me-1">@tag</span>
}
}
else
{
<span class="text-dark italic small">No tags</span>
}
</div>
</div>
<div class="row">
<div class="col-6">
<label class="form-label text-dark small mb-1">Status</label>
<div>
@if (Model.IsDone)
{
<span class="badge text-bg-success">Done</span>
}
else
{
<span class="badge text-bg-secondary">Open</span>
}
</div>
</div>
<div class="col-6">
<label class="form-label text-dark small mb-1">ID</label>
<div class="text-dark">@Model.Id</div>
</div>
</div>
<div>
<label class="form-label text-dark small mb-1">Created</label>
<div class="text-dark">@Model.CreatedUtc.ToLocalTime().ToString("F")</div>
</div>
</div>
}
</div>
<div class="modal-footer border-top border-light">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
New Features:
- Shows Category and Subcategory
- Displays Tags as badges
- Shows Task Status (Done/Open)
- Displays Task ID and Created timestamp
- Better visual organization with grouped fields
#
Pattern 4: Long-Running Operations with Polling (15–20 minutes)
Some operations take time—file processing, report generation, external API calls. Instead of making users wait on a loading spinner, you can show progress updates via polling.
#
4.1 Create a Simple Job Simulation
For the workshop, we'll simulate a background job with in-memory state:
File: Data/JobSimulator.cs (create new file)
namespace RazorPagesHtmxWorkshop.Data;
/// <summary>
/// Simulates a long-running background job for demonstration.
/// In production, you'd use a proper job queue (Hangfire, etc.).
/// </summary>
public static class JobSimulator
{
private static readonly Dictionary<string, JobStatus> _jobs = new();
private static readonly object _lock = new();
public class JobStatus
{
public string JobId { get; init; } = "";
public string State { get; set; } = "pending"; // pending, running, completed, failed
public int Progress { get; set; } = 0;
public string? Result { get; set; }
public string? Error { get; set; }
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
/// <summary>
/// Starts a new simulated job.
/// </summary>
public static JobStatus StartJob()
{
var jobId = Guid.NewGuid().ToString("N")[..8];
var status = new JobStatus
{
JobId = jobId,
State = "running",
Progress = 0
};
lock (_lock)
{
_jobs[jobId] = status;
}
// Simulate progress in background
_ = Task.Run(async () =>
{
try
{
for (var i = 1; i <= 10; i++)
{
await Task.Delay(500); // Simulate work
lock (_lock)
{
if (_jobs.TryGetValue(jobId, out var job))
{
job.Progress = i * 10;
}
}
}
lock (_lock)
{
if (_jobs.TryGetValue(jobId, out var job))
{
job.State = "completed";
job.Progress = 100;
job.Result = $"Report generated successfully at {DateTime.Now:HH:mm:ss}";
job.CompletedAt = DateTime.UtcNow;
}
}
}
catch (Exception ex)
{
lock (_lock)
{
if (_jobs.TryGetValue(jobId, out var job))
{
job.State = "failed";
job.Error = ex.Message;
job.CompletedAt = DateTime.UtcNow;
}
}
}
});
return status;
}
/// <summary>
/// Gets the current status of a job.
/// </summary>
public static JobStatus? GetStatus(string jobId)
{
lock (_lock)
{
return _jobs.TryGetValue(jobId, out var status) ? status : null;
}
}
/// <summary>
/// Cleans up old jobs (call periodically in production).
/// </summary>
public static void Cleanup(TimeSpan maxAge)
{
var cutoff = DateTime.UtcNow - maxAge;
lock (_lock)
{
var oldJobs = _jobs
.Where(kv => kv.Value.StartedAt < cutoff)
.Select(kv => kv.Key)
.ToList();
foreach (var id in oldJobs)
{
_jobs.Remove(id);
}
}
}
}
#
4.2 Create the Job Status Fragment
File: Pages/Tasks/Partials/_JobStatus.cshtml
@using RazorPagesHtmxWorkshop.Data
@model JobSimulator.JobStatus?
@*
Job Status Fragment
===================
Purpose: Display job progress and result
Model: JobStatus (or null if no job)
Design notes:
- Fragment contains polling trigger when job is running
- Polling stops automatically when job completes/fails
- Uses hx-trigger="every 1s" for active polling
- Conditional rendering based on job state
*@
<div id="job-status">
@if (Model is null)
{
<div class="card">
<div class="card-body">
<h5 class="card-title">Generate Report</h5>
<p class="card-text text-muted">
Click the button to generate a sample report.
This simulates a long-running operation.
</p>
<button type="button"
class="btn btn-primary"
hx-post="?handler=StartJob"
hx-target="#job-status"
hx-swap="outerHTML">
Start Report Generation
</button>
</div>
</div>
}
else if (Model.State == "running")
{
@* Active job - include polling trigger *@
<div class="card border-primary"
hx-get="?handler=JobStatus&jobId=@Model.JobId"
hx-trigger="every 1s"
hx-target="#job-status"
hx-swap="outerHTML">
<div class="card-body">
<h5 class="card-title">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Generating Report...
</h5>
<div class="progress mb-3" style="height: 25px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: @Model.Progress%"
aria-valuenow="@Model.Progress"
aria-valuemin="0"
aria-valuemax="100">
@Model.Progress%
</div>
</div>
<p class="card-text text-muted small mb-0">
Job ID: @Model.JobId | Started: @Model.StartedAt.ToLocalTime().ToString("HH:mm:ss")
</p>
</div>
</div>
}
else if (Model.State == "completed")
{
@* Completed - no polling *@
<div class="card border-success">
<div class="card-body">
<h5 class="card-title text-success">
<span class="me-2">✓</span> Report Complete
</h5>
<p class="card-text">@Model.Result</p>
<p class="card-text text-muted small">
Completed in @((Model.CompletedAt!.Value - Model.StartedAt).TotalSeconds.ToString("F1")) seconds
</p>
<button type="button"
class="btn btn-outline-primary"
hx-get="?handler=ResetJob"
hx-target="#job-status"
hx-swap="outerHTML">
Generate Another Report
</button>
</div>
</div>
}
else if (Model.State == "failed")
{
@* Failed - no polling *@
<div class="card border-danger">
<div class="card-body">
<h5 class="card-title text-danger">
<span class="me-2">✗</span> Report Failed
</h5>
<p class="card-text text-danger">@Model.Error</p>
<button type="button"
class="btn btn-outline-primary"
hx-get="?handler=ResetJob"
hx-target="#job-status"
hx-swap="outerHTML">
Try Again
</button>
</div>
</div>
}
</div>
#
4.3 Create the OOB Job Status Fragment
For job completion, we want to update both the job status AND show a message:
File: Pages/Tasks/Partials/_JobStatusWithOob.cshtml
@using RazorPagesHtmxWorkshop.Data
@model (JobSimulator.JobStatus Status, string Message, string AlertClass)
@*
Job Status with OOB Message
===========================
Purpose: Returns job status + out-of-band message in one response
Model: Tuple with status, message text, and Bootstrap alert class
Design notes:
- Primary content swaps into #job-status (via hx-target)
- OOB content swaps into #messages automatically
- htmx processes both fragments from single response
*@
@* Primary fragment - swapped into hx-target *@
<div id="job-status">
@if (Model.Status.State == "completed")
{
<div class="card border-success">
<div class="card-body">
<h5 class="card-title text-success">
<span class="me-2">✓</span> Report Complete
</h5>
<p class="card-text">@Model.Status.Result</p>
<p class="card-text text-muted small">
Completed in @((Model.Status.CompletedAt!.Value - Model.Status.StartedAt).TotalSeconds.ToString("F1")) seconds
</p>
<button type="button"
class="btn btn-outline-primary"
hx-get="?handler=ResetJob"
hx-target="#job-status"
hx-swap="outerHTML">
Generate Another Report
</button>
</div>
</div>
}
else
{
<div class="card border-danger">
<div class="card-body">
<h5 class="card-title text-danger">
<span class="me-2">✗</span> Report Failed
</h5>
<p class="card-text text-danger">@Model.Status.Error</p>
<button type="button"
class="btn btn-outline-primary"
hx-get="?handler=ResetJob"
hx-target="#job-status"
hx-swap="outerHTML">
Try Again
</button>
</div>
</div>
}
</div>
@* Out-of-band fragment - swaps into #messages automatically *@
<div id="messages" hx-swap-oob="true">
<div class="alert alert-@Model.AlertClass alert-dismissible fade show" role="alert">
@Model.Message
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
#
4.4 Add the Job Handlers
File: Pages/Tasks/Index.cshtml.cs (add to #region Long-Running Jobs)
#region Long-Running Jobs
/// <summary>
/// Starts a new simulated background job.
/// Returns the initial status fragment which begins polling.
/// </summary>
public IActionResult OnPostStartJob()
{
var job = JobSimulator.StartJob();
return Fragment("Partials/_JobStatus", job);
}
/// <summary>
/// Returns the current status of a job.
/// Called by polling requests.
/// When job completes, includes OOB swap for messages.
/// </summary>
public IActionResult OnGetJobStatus(string jobId)
{
var status = JobSimulator.GetStatus(jobId);
if (status is null)
{
return Fragment("Partials/_JobStatus", (JobSimulator.JobStatus?)null);
}
// For completed or failed jobs, use OOB partial
if (status.State is "completed" or "failed")
{
var message = status.State == "completed"
? "Report generation completed successfully!"
: $"Report generation failed: {status.Error}";
var alertClass = status.State == "completed" ? "success" : "danger";
return Fragment("Partials/_JobStatusWithOob", (status, message, alertClass));
}
// Running jobs use simple status fragment
return Fragment("Partials/_JobStatus", status);
}
/// <summary>
/// Resets the job UI to initial state.
/// </summary>
public IActionResult OnGetResetJob()
{
return Fragment("Partials/_JobStatus", (JobSimulator.JobStatus?)null);
}
#endregion
#
4.5 Add Job Status to the Page
File: Pages/Tasks/Index.cshtml (add section for job demo)
@* Long-Running Job Demo *@
<div class="row mt-4">
<div class="col-12">
<h2 class="h5">Background Job Demo</h2>
<partial name="Partials/_JobStatus" model="@((JobSimulator.JobStatus?)null)" />
</div>
</div>
Add the using statement at the top:
@using RazorPagesHtmxWorkshop.Data
#
Complete Handler Inventory (Lab 5)
#
Verification Checklist
Before completing the workshop, verify these behaviors:
#
Dynamic Tags
- "Add Tag" button appends new tag input
- Each tag has a working remove button (client-side, instant)
- Multiple tags can be added
- Tags are included when form submits
- Form reset clears all tags
- Flash message shows tag count on success
#
Dependent Dropdowns
- Selecting a category updates subcategory options
- Changing category updates subcategory again
- Clearing category disables subcategory
- Selected values persist on validation failure
- Category and subcategory are saved with task
#
Enhanced Task Details
- Details modal shows Category and Subcategory
- Details modal shows all Tags as badges
- Details modal shows Task Status (Done/Open)
- Details modal shows Task ID and Created timestamp
#
Polling
- Starting job shows progress card
- Progress updates every second
- Network tab shows polling requests
- Polling stops when job completes
- Success/failure state displays correctly
#
OOB Swaps
- Job completion updates both job card and messages
- Single network response contains both fragments
- Messages area shows appropriate alert
#
Key Fixes and Improvements in Lab 5
#
1. Simplified Tag Implementation
Before (documented):
- Required server-side removal handler
- Complex index tracking with
hx-vals - Individual IDs for each tag row
After (actual code):
- Client-side removal with
hx-on:click - Simple
name="Input.Tags"binding - No index management needed
#
2. Fixed Category/Subcategory Attributes
Before (old syntax):
@(Model.Options.Count == 0 ? "disabled" : "")
After (proper Razor):
disabled="@(Model.Options.Count == 0)"
#
3. Enhanced Task Details Modal
Now shows complete task information:
- Category and Subcategory
- Tags (with badge styling)
- Status (Done/Open badge)
- Task ID
- Created timestamp
#
4. Proper Parameter Binding
Uses [FromQuery(Name = "Input.Category")] for subcategory handler to correctly bind the parameter from the htmx request.
#
Key Takeaways
#
Pattern Summary
#
The Server Controls Everything
In all four patterns, the server decides:
- What HTML to render (the fragments)
- Whether to continue polling (by including/excluding trigger)
- What else to update (via OOB fragments)
- Validation and business logic (tags cleanup, category relationships)
This is the power of hypermedia: the server remains in control of application state.
#
Troubleshooting
#
Common Issues
#
Workshop Wrap-Up
Congratulations! You've completed Lab 5 and mastered:
- Dynamic forms with add/remove functionality
- Dependent dropdowns with cascading selection
- Long-running operations with polling
- Out-of-band swaps for multi-region updates
- Enhanced UI patterns showing complete data models
Next Steps:
- Apply these patterns to your real projects
- Explore htmx extensions (SSE, WebSockets)
- Read the htmx documentation at htmx.org
- Join the htmx community
Thank you for completing the workshop!