An ASP.NET and htmx meta-framework
The name RazorX represents the combination of ASP.NET Razor Components on the server with htmx on the client. ASP.NET Minimal APIs provide the request-response processing between the client and server. Razor Components are only used for server-side templating, and there are no dependencies on Blazor for routing or interactivity.
Install the required dependencies, if necessary.
- .NET SDK >= 9.0
- Node.js or similar JS runtime for tailwindcss
- Azurite VS Code extension
Download and install the template
- Download the
Package/RazorX.Template.1.0.0-beta.nupkg
- Install the template
dotnet new install RazorX.Template.1.0.0-beta.nupkg
- Create a new app with
dotnet new razorx
[OR]
Build and install the template
- Clone the razorx repository.
- Use the /RxTemplatePack/makefile to build and install the template. If you're on Windows, you may need to create the template manually by copying the RxTemplate folder to RxTemplatePack/content and running the dotnet CLI commands in the makefile.
- Create a new app with
dotnet new razorx
.
The following diagram describes the flow of a request. The basic concept is to first route the request through a series of middleware, both global and endpoint specific (IEndpointFilters
). This applies common behaviors like anti-forgery token validation, authorization policy enforcement, and model validation. Next, the request is routed to a specific handler (IRequestHandler Delegate
) for processing application logic. The request handler will usually return a RazorComponentResult
for creating an HTML response. Finally, htmx may modify the DOM to swap partial content and trigger events specified in response headers. The events are handled with JavaScript event handlers in razorx.js
.
The following covers the basics of creating a new page, adding a model and validation, and making it reactive with htmx. After working through this, I recommend checking out the included examples. The template includes many components which work well with the htmx hypermedia approach and have client-side JavaScript behaviors for an enhanced experience.
- Create a Demo application
- Create a new page
- Add a model
- Add a Create TODO feature
- Add a Complete TODO feature
- Add a Delete TODO feature
- Add a Update TODO feature
- EXTRA CREDIT - Add a Change Validation feature
- Create a new
Demo
folder. - Run the
dotnet new razorx
command in a terminal in theDemo
folder to spawn a new RazorX application. - Run the
dotnet watch
command in the terminal to launch the app. The browser should launch the app. If not, open a browser and go tohttps://localhost:44376/
. - Once you verify the app runs, go ahead and shutdown the server using
Ctrl-C
in the terminal.
- Create a new
Todos
folder underComponents
. - Create a new
TodosPage.razor
file. - Create a new
TodosHandler.cs
file.
Add the following code to the TodosPage.razor
file.
<HeadContent>
<title>TODOs</title>
</HeadContent>
<div id="todos-page">
<div class="flex justify-center">
<article class="prose">
<div class="flex justify-center">
<h2>TODOs</h2>
</div>
</article>
</div>
<div class="flex justify-center w-full">
<div class="w-2xl">
<div class="flex flex-col justify-center w-full">
</div>
</div>
</div>
</div>
Add the following code to the TodosHandler.cs
file.
- All pages have an associated
IRequestHandler
. This interface has one method,MapRoutes
, which maps the endpoints to delegates. - Endpoint filters may be applied to provide route-specific behavior. The filters are executed in the order they are applied, so behaviors like authorization should be defined first.
- The
WithRxRootComponent
filter indicates that theRazorComponentResult
should be embedded in the layoutIRootComponent
. TheUseRxRouter
call inProgram.cs
specifies a default root component,Components/Layout/App.razor
, but an override layout may be specified withWithRxRootComponent<T>
where T is anIRootComponent
.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
}
public static IResult Get(
HttpResponse response) {
return response.RenderComponent<TodosPage>();
}
}
Add a nav link to the new page by opening the Components/Layout/Nav.razor
file and adding the following @* NEW *@
code.
<div class="z-50 bg-base-100 min-h-full w-96 p-4">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-semibold text-primary">Demo Menu</h2>
@* ...code omitted *@
</div>
<ul class="menu w-full">
<li>
<RxNavItem
NavItemRoute="/"
CurrentRouteClass="font-semibold">
Home
</RxNavItem>
</li>
@* NEW *@
<li>
<RxNavItem
NavItemRoute="/todos"
CurrentRouteClass="font-semibold">
TODOs
</RxNavItem>
</li>
@* END NEW *@
<li>
<RxNavItem
NavItemRoute="/examples"
MatchPartial="@(true)"
CurrentRouteClass="font-semibold"
>
Examples
</RxNavItem>
</li>
@* ...code omitted *@
</ul>
</div>
Save all files and run the dotnet watch
command in the terminal to launch the app. The TODOs
page should be available from the nav menu.
Create a new TodosDbContext.cs
file in Components/Todos
and add the following code.
- When developing an application, you will most likely use
EntityFramework
for persistence. - The below code is a fake implementation for demonstration purposes only, and it is up to you to bring your on EF implementation.
namespace Demo.Components.Todos;
// Fake DbContext - This would be an EF context in a real application and would NOT be defined here!
// The use of this object in the handler would be different:
// 1. The DbContext would be injected into the handler delegates (i.e., Get, Post, etc.,) and NOT be static.
// 2. The delegate handlers would be async Task<IResult> methods for EF async/await operations.
public static class FakeDbContext {
public static readonly IList<Todo> Todos = [];
}
// Todo Entity - This would be an EF entity in a real application and would NOT be defined here!
public class Todo {
public string Id { get; set; } = null!;
public string Title { get; set; } = null!;
public string Description { get; set; } = null!;
public bool IsComplete { get; set; }
public DateTime LastUpdated { get; set; }
}
Create a new TodosModel.cs
file in Components/Todos
and add the following code.
- This is the model for our TODO feature. While it may begin with a shape that is nearly identical to the entity, the entity should not be substituted as the model. In almost all cases, the model shape will diverge from the entity.
- The model properties should be nullable if the property will be bound to an input element that the user may leave blank, even if this will be caught as a validation error. Think of a required
<input type="number" name="Quantity" />
that will be deserialized into aQuantity
property. The user may choose to leave the input element blank, which will be deserialized as null. The validator would add a validation error for this condition, and the user would see the error and correct it. Therequired
element attribute may be used to apply upfront validation, but browser validation implementations vary and are limited, so I prefer to use server validation only to keep everything consistent. - This example uses a
record
type for immutability, butclass
andstruct
may also be used for models if preferred.
namespace Demo.Components.Todos;
public record TodoModel
(
string? Id = null,
string? Title = null,
string? Description = null,
bool IsComplete = false,
DateTime? LastUpdated = null
);
Create a new TodosForm.razor
file in Components/Todos
and add the following code.
- This component represents an HTML fragment for the TODO input elements that serialize to the model's
Title
andDescription
properties. - This fragment will be used in the
TodosNew
andTodosUpdateModal
components, which is why it is a separate component. - Any component that binds to a model must implement the
IComponentModel<T>
interface, where T is the type of model. - RazorX component
Id
parameters map to HTML Elementid
attributes and must be unique. If anId
is not set, RazorX will generate a unique value for the element. However, in many cases theId
will need to be a known value for htmxhx-target
attributes or JavaScriptgetElementById
operations. In these cases, theId
must be computed and assigned. - The
TodosForm
component uses the RazorXField
component for theTitle
property binding and theMemoField
component for theDescription
property binding. - All RazorX components will use the assigned
{Id}-input
format for the underlying HTML input elementid
. - The
PropertyName
parameter is what binds the component to the model's property. ThePropertyName
translates to the HTML input element'sname
attribute, which is the key used for the key-value pair on form submission. For easier refactoring, it is recommended to use thenameof
expression to set thePropertyName
instead of a hard-coded string. Components/Rx
contains many pre-built components to accelerate development. They are separated intoHeadless
andSkinned
.Headless
components implement structure and behavior, andSkinned
components wrap associatedHeadless
components with CSS styling.
@implements IComponentModel<TodoModel>
<div>
<!-- Title Field -->
<Field
Id="@($"{nameof(Model.Title)}{Model.Id}")"
PropertyName="@(nameof(Model.Title))"
Value="@(Model.Title)"
Label="Title"
InputType="text"
UseOpacityForValidationErrors="@(true)"
maxlength="80"
placeholder="e.g., Learn the RazorX meta-framework!">
</Field>
</div>
<div>
<!-- Description Memo Field -->
<MemoField
Id="@($"{nameof(Model.Description)}{Model.Id}")"
PropertyName="@(nameof(Model.Description))"
Value="@(Model.Description)"
Label="Description"
MaxLength="500"
UseOpacityForValidationErrors="@(true)"
placeholder="e.g., This includes reading the htmx documentation and checking out Tailwind and daisyUI.">
</MemoField>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Create a new TodosNew.razor
file in Components/Todos
and add the following code.
- This component represents the fragment that contains the form for creating new TODOs.
- It embeds the
TodosForm
component from above as a child component and passes the model parameter down. - The HTML form element's
hx-
attributes define the htmx behaviors for the form. - The
hx-post
tells htmx to issue a POST request to the/todos
endpoint on form submission. - The
hx-target
is the element that will be targeted in the response. In this case, the#
means use theid
to identify the element. CSS selector syntax is used by htmx for element identification. - The
hx-swap
defines the swap strategy htmx should use to place the response in the targeted element. Most often this will beinnerHTML
(the default) orouterHTML
to replace the entire element. In this case, thebeforeend
strategy is used to tell htmx to append the new TODO to the end of the list contained in the<div id="todos-list">
element.
@implements IComponentModel<TodoModel>
<div id="todos-new" class="flex flex-col w-full gap-y-3">
<form hx-post="/todos" hx-target="#todos-list" hx-swap="beforeend" novalidate>
<TodosForm Model="@(Model)" />
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
Submit
</button>
</div>
</form>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Create a new TodosItem.razor
file in Components/Todos
and add the following code.
- This component represents a TODO list item.
- It uses the
RxUtcToLocal
component to convert a UTC date from the server to the local timezone of the client. It is recommended to always persist date-time values as UTC since they may then be converted to whatever timezone is most appropriate.
@implements IComponentModel<TodoModel>
<div id="@($"todos-item-{Model.Id}")" class="card card-border border-base-300 bg-base-200 w-full mb-2">
<div class="card-body">
<div class="flex gap-2 justify-between items-center">
<div class="flex min-w-20">
</div>
<div class="flex grow justify-center py-2">
<h2 class="card-title">@(Model.Title)</h2>
</div>
<div class="flex justify-end gap-x-2 min-w-20">
</div>
</div>
<p class="whitespace-pre">
@(Model.Description)
</p>
<div class="flex justify-end text-xs">
<RxUtcToLocal DateInput="@(Model.LastUpdated!.Value)" />
</div>
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosPage Component with the @* New *@
Component Fragments
- Declaring a model for the page component is done by implementing
IComponentModel
. In this case, a list of TODOs is used since the page will display all the TODOs, as well as provide a way to create a new TODO. - The
TodosNew
component is passed a newTodoModel
since this will be an empty form.
@* NEW *@
@implements IComponentModel<IEnumerable<TodoModel>>
@* END NEW *@
<HeadContent>
<title>TODOs</title>
</HeadContent>
<div id="todos-page">
<div class="flex justify-center">
<article class="prose">
<div class="flex justify-center">
<h2>TODOs</h2>
</div>
</article>
</div>
<div class="flex justify-center w-full">
<div class="w-2xl">
<div class="flex flex-col justify-center w-full">
@* NEW *@
<div id="todos-list">
@foreach (var todo in Model) {
<TodosItem Model="@(todo)" />
}
</div>
<TodosNew Model="@(new())"/>
@* END NEW *@
</div>
</div>
</div>
</div>
@* NEW *@
@code {
[Parameter] public IEnumerable<TodoModel> Model { get; set; } = null!;
}
@* END NEW *@
Update the TodosHandler with the //New
component fragments for creating TODOs
- The delegate
Get
is updated to query the fake DbContext and project list ofTodoModel
s. - A new POST route and delegate is added to the handler. The model is bound to the delegate. RazorX uses an htmx extension that coverts form key-value pairs (the htmx default) into JSON. RazorX includes custom JSON converters for deserializing this JSON into model objects.
- The delegate updates the model's
Id
andLastUpdated
properties before binding it to the component in the response. - The
IHxTriggers
builder is injected into the POST delegate.HxTriggers
are used to set response headers that htmx will dispatch events on. Depending on the type of trigger, the event may be dispatched immediately on receiving the response, after htmx processes the swap, or after the DOM is settled. For example, setting focus to an element should wait until the DOM is settled. In this specific case, the success toast is popped and theTitle
input element is focused. - The handlers for the
IHxTriggers
dispatched events are located in thewwwroot/js/razorx.js
file, but no customization should be needed for any implementations in this file.IHxTriggers
is capable of dispatching custom events, and in this case the developer is responsible for implementing the JavaScript handlers.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
//NEW
router.MapPost("/todos", Post)
.AllowAnonymous();
//END NEW
}
public static IResult Get(
HttpResponse response) {
//NEW
//return response.RenderComponent<TodosPage>();
var model = FakeDbContext.Todos.Select(x => new TodoModel(
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
//END NEW
}
//NEW
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model) {
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
//END NEW
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). You should now be able to add TODOs to the list.
Update the TodosModel with the //New
code.
- The model is now further deviating from the EF entity. In this case, we're adding a
ResetForm
flag to indicate that the form should be reset after successfully creating a new TODO.
namespace Demo.Components.Todos;
public record TodoModel
(
//NEW
bool ResetForm = false,
//END NEW
string? Id = null,
string? Title = null,
string? Description = null,
bool IsComplete = false,
DateTime? LastUpdated = null
);
Update the TodosItem component with the @* NEW *@
code.
- The
TodosItem
component is returned after a successful POST call to add a new TODO. - The model's
ResetForm
flag is evaluated to see if a newTodosNew
component should be included in the response.
@implements IComponentModel<TodoModel>
@* NEW *@
@if (Model.ResetForm) {
<TodosNew Model="@(new())" />
}
@* END NEW *@
<div id="@($"todos-item-{Model.Id}")" class="card card-border border-base-300 bg-base-200 w-full mb-2">
<div class="card-body">
<div class="flex gap-2 justify-between items-center">
<div class="flex min-w-20">
</div>
<div class="flex grow justify-center py-2">
<h2 class="card-title">@(Model.Title)</h2>
</div>
<div class="flex justify-end gap-x-2 min-w-20">
</div>
</div>
<p class="whitespace-pre">
@(Model.Description)
</p>
<div class="flex justify-end text-xs">
<RxUtcToLocal DateInput="@(Model.LastUpdated!.Value)" />
</div>
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosNew component with the @* NEW *@
code.
- The
hx-swap-oob="true"
attribute is added to theTodosNew
component's main container div. This tells htmx that this content may exist as an additional fragment in a response, and when it does to swap the content in the DOM. - The default swap strategy for oob (out of band) swaps is
outerHTML
, but a different strategy may be specified in place oftrue
. The typical swap strategy for oob swaps will beouterHTML
.
@implements IComponentModel<TodoModel>
@* NEW *@
@* <div id="todos-new" class="flex flex-col w-full gap-y-3"> *@
<div id="todos-new" class="flex flex-col w-full gap-y-3" hx-swap-oob="true">
@* END NEW *@
<form hx-post="/todos" hx-target="#todos-list" hx-swap="beforeend" novalidate>
<TodosForm Model="@(Model)" />
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
Submit
</button>
</div>
</form>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosHandler
with the //NEW
code.
- Update the
Get
delegate with theResetForm
flag set to false. - Update the
Post
delegate with theResetForm
flag set to true. This will include theTodosNew
component in theTodosItem
response and htmx will replace the existing form with a new one, effectively clearing the prior inputs.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous();
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
//NEW
false,
//END NEW
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model) {
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
//NEW
ResetForm = true,
//END NEW
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). The form should reset after adding a TODO.
Create a new TodosValidator.cs
file in Components/Todos
and add the following code.
- Validators are extended from FluentValidation's
AbstractValidator
. - Errors are collected in a request-scoped
ValidationContext
object. - The
ValidationContext
may be injected anywhere it is needed, including theIRequestHandler
delegates andRazorComponents
. - The model must be deserialized from the request's JSON payload before validation. This is why model properties should be nullable if the user may choose to not provide a value, even if the property is required.
- In this case, the model's
Title
andDescription
properties are validated for required value and max length.
using FluentValidation;
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosValidator : Validator<TodoModel> {
public TodosValidator(ValidationContext validationContext) : base(validationContext) {
RuleFor(x => x.Title)
.NotEmpty()
.WithMessage("Title is Required.")
.MaximumLength(80)
.WithMessage("Title has a maximum length of 80 characters.");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is Required.")
.MaximumLength(500)
.WithMessage("Description has a maximum length of 500 characters.");
}
}
Update the TodosHandler
with the //NEW
code.
- Adding the
WithRxValidation<T>
filter where T is aValidator
will trigger validation. - Validation occurs before the
IRequestHandler
delegate is invoked. - The
ValidationContext
is injected into thePost
delegate. - If the
ValidationContext
contains errors, thePost
delegate responds with theTodosNew
component instead of theTodosItem
component. - All RazorX components inject the
ValidationContext
and have built-in validation error display styling. - The response extension methods
HxRetarget
andHxReswap
are used to set response headers for htmx. These headers will override the originalhx-target
andhx-swap
attributes for the request. They may also be used if thehx-target
orhx-swap
was not specified to begin with.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous()//;
//NEW
.WithRxValidation<TodosValidator>();
//END NEW
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
false,
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model,
//NEW
ValidationContext validationContext) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todos-new");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosNew, TodoModel>(model);
}
//END NEW
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
ResetForm = true,
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). You should not be able to add invalid TODOs.
Update the TodosItem component with the @* NEW *@
code.
- A RazorX
Checkbox
component is added to allow the user to mark the TODO as complete. It is bound to the model'sIsComplete
property. - The
hx-patch
,hx-target
, andhx-swap
attributes are added directly to theCheckbox
. Thehx-trigger
attribute is not used, but may be added if needed. Sensible defaults are used by htmx when attributes are omitted. In this case, the trigger is theonchange
event. - When the
Checkbox
is checked, htmx will send a PATCH request to the endpoint, including theId
of the TODO item. - The response will replace the entire TODO item, and a "COMPLETED" badge will be displayed if the TODO item is marked as completed.
@implements IComponentModel<TodoModel>
@if (Model.ResetForm) {
<TodosNew Model="@(new())" />
}
<div id="@($"todos-item-{Model.Id}")" class="card card-border border-base-300 bg-base-200 w-full mb-2">
<div class="card-body">
<div class="flex gap-2 justify-between items-center">
<div class="flex min-w-20">
@* NEW *@
<div>
<Checkbox
Id="@($"todos-item-completed-{Model.Id}")"
PropertyName="@(nameof(Model.IsComplete))"
IsChecked="@(Model.IsComplete)"
aria-label="Complete"
hx-patch="@($"/todos/{Model.Id}")"
hx-target="@($"#todos-item-{Model.Id}")"
hx-swap="outerHTML"
>
</Checkbox>
</div>
@* END NEW *@
</div>
<div class="flex grow justify-center py-2">
<h2 class="card-title">@(Model.Title)</h2>
</div>
<div class="flex justify-end gap-x-2 min-w-20">
</div>
</div>
@* NEW *@
<div class="flex justify-center">
@if (Model.IsComplete) {
<span class="bg-success text-success-content rounded-full px-2 font-semibold">COMPLETED</span>
} else {
<span> </span>
}
</div>
@* END NEW *@
<p class="whitespace-pre">
@(Model.Description)
</p>
<div class="flex justify-end text-xs">
<RxUtcToLocal DateInput="@(Model.LastUpdated!.Value)" />
</div>
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosHandler
with the //NEW
code.
- Add the
IRequestHandler
endpoint and delegate mapping for the PATCH request. This includes the{id}
route segment for the TODOId
. - The
Patch
delegate reads the todo entity from the fake DbContext, updates theIsComplete
property, and projects a new todo model for binding to theTodosItem
component in the response. - If the todo entity is not found, a
TypedResults.NoContent
(204) response is returned. HTTP response codes are evaluated for specific meaning by htmx. 204 tells htmx to not process the response. In this case, using aTypedResults.NotFound
(404) might be a better option. Using this code would invoke htmx error processing, as does any code >= 400. RazorX will redirect a user to an error page when htmx signals an error. For a situation like this, the 404 would force the user to return to the TODO page and the removed item would no longer be on the list. Of course, developers may choose to handle concurrency situations more elaborately.The Kitchen Sync
example does this by pre-fetching an item before an update operation is processed and displays a message to the user that their data is stale. My general recommendation is to return a 404 so the user is required to navigate to the fresh page to continue.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
//NEW
router.MapPatch("/todos/{id}", Patch)
.AllowAnonymous();
//END NEW
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
false,
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model,
ValidationContext validationContext) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todos-new");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosNew, TodoModel>(model);
}
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
ResetForm = true,
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
//NEW
public static IResult Patch(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NoContent();
}
todo.IsComplete = !todo.IsComplete;
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"Updated TODO to {(todo.IsComplete ? "completed" : "not completed")}."))
.Add(new HxFocusTrigger($"#todos-item-completed-{todo.Id}"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
//END NEW
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). You should be able to mark TODOs as complete.
Update the TodosItem component with the @* NEW *@
code.
- Add a
RxModalTrigger
component to display a modal confirmation dialog. RxModalTrigger
s are HTML button elements.- The
ModalId
is theid
attribute value of thedialog
element. - The
RouteValue
is the value to append to thehx-
request route. - The
TextNodeValue
is any item-specific text that needs to be displayed in the dialog.
@implements IComponentModel<TodoModel>
@if (Model.ResetForm) {
<TodosNew Model="@(new())" />
}
<div id="@($"todos-item-{Model.Id}")" class="card card-border border-base-300 bg-base-200 w-full mb-2">
<div class="card-body">
<div class="flex gap-2 justify-between items-center">
<div class="flex min-w-20">
<div>
<Checkbox
Id="@($"todos-item-completed-{Model.Id}")"
PropertyName="@(nameof(Model.IsComplete))"
IsChecked="@(Model.IsComplete)"
aria-label="Complete"
hx-patch="@($"/todos/{Model.Id}")"
hx-target="@($"#todos-item-{Model.Id}")"
hx-swap="outerHTML"
>
</Checkbox>
</div>
</div>
<div class="flex grow justify-center py-2">
<h2 class="card-title">@(Model.Title)</h2>
</div>
<div class="flex justify-end gap-x-2 min-w-20">
@* NEW *@
<RxModalTrigger
ModalId="delete-todo-modal"
RouteValue="@(Model.Id)"
TextNodeValue="@($"Delete {Model.Title}?")"
class="btn btn-square btn-sm btn-error"
aria-label="@($"Delete {Model.Title}?")">
<span class="text-xl">✕</span>
</RxModalTrigger>
@* END NEW *@
</div>
</div>
<div class="flex justify-center">
@if (Model.IsComplete) {
<span class="bg-success text-success-content rounded-full px-2 font-semibold">COMPLETED</span>
} else {
<span> </span>
}
</div>
<p class="whitespace-pre">
@(Model.Description)
</p>
<div class="flex justify-end text-xs">
<RxUtcToLocal DateInput="@(Model.LastUpdated!.Value)" />
</div>
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosPage component with the @* NEW *@
code.
- Add the HTML
dialog
element to the page and assign it theid
specified in theRxModalTrigger
'sModalId
. - The
RxModalTextNode
is used as the modal's title. - The
RxModalDismiss
component is a button that will close the modal. - The
RxModalAction
component is a button that can issue anhx-
request. In this case, the request is a DELETE to the/todos
endpoint. TheRouteValue
set on theRxModalTrigger
will be appended to the endpoint, so the final endpoint isDELETE: todos/{id}
. - The
hx-disabled-elt
attribute will disable the button while the request is in-flight. - The
RxModalAction
component will not close the modal when clicked. This is important because the server may need to return validation errors to the modal in some cases.
@implements IComponentModel<IEnumerable<TodoModel>>
<HeadContent>
<title>TODOs</title>
</HeadContent>
@* NEW *@
<dialog id="delete-todo-modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<div class="flex justify-between items-center bg-base-300 p-5 rounded-sm">
<div class="text-lg font-bold">
<RxModalTextNode />
</div>
</div>
<form method="dialog">
<div class="p-5">
This is a destructive operation. Are you sure?
</div>
<div class="modal-action">
<RxModalDismiss autofocus class="btn btn-neutral">
Cancel
</RxModalDismiss>
<RxModalAction
hx-delete="/todos"
hx-disabled-elt="this"
class="btn btn-error">
Delete
</RxModalAction>
</div>
</form>
</div>
</dialog>
@* END NEW *@
<div id="todos-page">
<div class="flex justify-center">
<article class="prose">
<div class="flex justify-center">
<h2>TODOs</h2>
</div>
</article>
</div>
<div class="flex justify-center w-full">
<div class="w-2xl">
<div class="flex flex-col justify-center w-full">
<div id="todos-list">
@foreach (var todo in Model) {
<TodosItem Model="@(todo)" />
}
</div>
<TodosNew Model="@(new())"/>
</div>
</div>
</div>
</div>
@code {
[Parameter] public IEnumerable<TodoModel> Model { get; set; } = null!;
}
Update the TodosHandler
with the //NEW
code.
- Add the
IRequestHandler
endpoint and delegate mapping for the DELETE request. This includes the{id}
route segment for the TODOId
. - The
Delete
delegate will remove the TODO from the fake DbContext, if it exists. We don't care if it has previously been deleted by a different user since the action is DELETE. - The
HxCloseModalTrigger
is added to the response to signal the close of the modal. - The
HxRetarget
andHxReswap
extension methods are used to specify theouterHTML
of the TODO item being deleted. These attributes could have been applied to theRxModalAction
component instead. - The response is
TypedResults.Ok
(200). When a 200 is returned for a DELETE, htmx will remove the targeted element using the specified swap strategy.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
router.MapPatch("/todos/{id}", Patch)
.AllowAnonymous();
//NEW
router.MapDelete("/todos/{id}", Delete)
.AllowAnonymous();
//END NEW
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
false,
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model,
ValidationContext validationContext) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todos-new");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosNew, TodoModel>(model);
}
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
ResetForm = true,
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Patch(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NoContent();
}
todo.IsComplete = !todo.IsComplete;
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"Updated TODO to {(todo.IsComplete ? "completed" : "not completed")}."))
.Add(new HxFocusTrigger($"#todos-item-completed-{todo.Id}"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
//NEW
public static IResult Delete(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is not null) {
FakeDbContext.Todos.Remove(todo);
}
hxTriggers
.With(response)
.Add(new HxCloseModalTrigger("#delete-todo-modal"))
.Add(new HxToastTrigger("#success-toast", $"TODO deleted."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
response.HxRetarget($"#todos-item-{id}");
response.HxReswap("outerHTML");
return TypedResults.Ok();
}
//END NEW
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). You should be able to delete TODOs.
Update the TodosItem component with the @* NEW *@
code.
- Add a
RxModalTrigger
component to display a modal edit dialog. RxModalTrigger
s are HTML button elements.- The
ModalId
is theid
attribute value of thedialog
element. - The
RouteValue
is the value to append to thehx-
request route. - The
TextNodeValue
is any item-specific text that needs to be displayed in the dialog.
@implements IComponentModel<TodoModel>
@if (Model.ResetForm) {
<TodosNew Model="@(new())" />
}
<div id="@($"todos-item-{Model.Id}")" class="card card-border border-base-300 bg-base-200 w-full mb-2">
<div class="card-body">
<div class="flex gap-2 justify-between items-center">
<div class="flex min-w-20">
<div>
<Checkbox
Id="@($"todos-item-completed-{Model.Id}")"
PropertyName="@(nameof(Model.IsComplete))"
IsChecked="@(Model.IsComplete)"
aria-label="Complete"
hx-patch="@($"/todos/{Model.Id}")"
hx-target="@($"#todos-item-{Model.Id}")"
hx-swap="outerHTML"
>
</Checkbox>
</div>
</div>
<div class="flex grow justify-center py-2">
<h2 class="card-title">@(Model.Title)</h2>
</div>
<div class="flex justify-end gap-x-2 min-w-20">
@* NEW *@
<RxModalTrigger
Id="@($"todos-item-edit-{Model.Id}")"
ModalId="update-todo-modal"
RouteValue="@(Model.Id)"
TextNodeValue="@($"Update {Model.Title}?")"
class="btn btn-square btn-sm btn-primary"
aria-label="@($"Update {Model.Title}?")">
<span class="text-xl">✎</span>
</RxModalTrigger>
@* END NEW *@
<RxModalTrigger
ModalId="delete-todo-modal"
RouteValue="@(Model.Id)"
TextNodeValue="@($"Delete {Model.Title}?")"
class="btn btn-square btn-sm btn-error"
aria-label="@($"Delete {Model.Title}?")">
<span class="text-xl">✕</span>
</RxModalTrigger>
</div>
</div>
<div class="flex justify-center">
@if (Model.IsComplete) {
<span class="bg-success text-success-content rounded-full px-2 font-semibold">COMPLETED</span>
} else {
<span> </span>
}
</div>
<p class="whitespace-pre">
@(Model.Description)
</p>
<div class="flex justify-end text-xs">
<RxUtcToLocal DateInput="@(Model.LastUpdated!.Value)" />
</div>
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosPage component with the @* NEW *@
code.
- Add the HTML
dialog
element to the page and assign it theid
specified in theRxModalTrigger
'sModalId
. - The
RxModalTextNode
is used as the modal's title. - The
RxModalAsyncContent
component enables the modal content to be asynchronously rendered from the server. - The
RenderFromRoute
is the GET endpoint. - When the
RxModalTrigger
triggers the opening of the modal, theRenderFromRoute
endpoint will have theRouteValue
appended and request will be sent.
@implements IComponentModel<IEnumerable<TodoModel>>
<HeadContent>
<title>TODOs</title>
</HeadContent>
<dialog id="delete-todo-modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<div class="flex justify-between items-center bg-base-300 p-5 rounded-sm">
<div class="text-lg font-bold">
<RxModalTextNode />
</div>
</div>
<form method="dialog">
<div class="p-5">
This is a destructive operation. Are you sure?
</div>
<div class="modal-action">
<RxModalDismiss autofocus class="btn btn-neutral">
Cancel
</RxModalDismiss>
<RxModalAction
hx-delete="/todos"
hx-disabled-elt="this"
class="btn btn-error">
Delete
</RxModalAction>
</div>
</form>
</div>
</dialog>
@* NEW *@
<dialog id="update-todo-modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<div class="flex justify-between items-center bg-base-300 p-5 rounded-sm">
<div class="text-lg font-bold">
<RxModalTextNode />
</div>
</div>
<form method="dialog">
<RxModalAsyncContent
Id="todo-update-modal-content"
RenderFromRoute="/todos">
<FallbackContent />
</RxModalAsyncContent>
</form>
</div>
</dialog>
@* END NEW *@
<div id="todos-page">
<div class="flex justify-center">
<article class="prose">
<div class="flex justify-center">
<h2>TODOs</h2>
</div>
</article>
</div>
<div class="flex justify-center w-full">
<div class="w-2xl">
<div class="flex flex-col justify-center w-full">
<div id="todos-list">
@foreach (var todo in Model) {
<TodosItem Model="@(todo)" />
}
</div>
<TodosNew Model="@(new())"/>
</div>
</div>
</div>
</div>
@code {
[Parameter] public IEnumerable<TodoModel> Model { get; set; } = null!;
}
Create a new TodosUpdateModal.razor
file in Components/Todos
and add the following code.
- This component is the content that will be swapped in place of the
RxModalAsyncContent
. - The
Id
of theRxModalAsyncContent
component and the content component's container elementid
attribute must be the same value. - This component will embed the existing
TodosForm
component and will bind the model to the input elements.
@implements IComponentModel<TodoModel>
<div id="todo-update-modal-content">
<TodosForm Model="@(Model)" />
<div class="modal-action">
<RxModalDismiss autofocus class="btn btn-neutral">
Cancel
</RxModalDismiss>
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosHandler
with the //NEW
code.
- Add the
IRequestHandler
endpoint and delegate mapping for the GETGetUpdateModal
request. This includes the{id}
route segment for the TODOId
. - The
GetUpdateModal
delegate will read the TODO from the fake DbContext. If the TODO is not found because it has been deleted by a different user, theTypedResults.NotFound
is returned. This will signal htmx to throw an error, and RazorX will redirect to the error page which will force the user to navigate to a fresh TODOs page. - The projected model is bound to the
TodosUpdateModal
component and returned in the response.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
router.MapPatch("/todos/{id}", Patch)
.AllowAnonymous();
router.MapDelete("/todos/{id}", Delete)
.AllowAnonymous();
//NEW
router.MapGet("/todos/{id}", GetUpdateModal)
.AllowAnonymous();
//END NEW
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
false,
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model,
ValidationContext validationContext) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todos-new");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosNew, TodoModel>(model);
}
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
ResetForm = true,
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Patch(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NoContent();
}
todo.IsComplete = !todo.IsComplete;
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"Updated TODO to {(todo.IsComplete ? "completed" : "not completed")}."))
.Add(new HxFocusTrigger($"#todos-item-completed-{todo.Id}"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Delete(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is not null) {
FakeDbContext.Todos.Remove(todo);
}
hxTriggers
.With(response)
.Add(new HxCloseModalTrigger("#delete-todo-modal"))
.Add(new HxToastTrigger("#success-toast", $"TODO deleted."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
response.HxRetarget($"#todos-item-{id}");
response.HxReswap("outerHTML");
return TypedResults.Ok();
}
//NEW
public static IResult GetUpdateModal(
HttpResponse response,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NotFound();
}
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
return response.RenderComponent<TodosUpdateModal, TodoModel>(model);
}
//END NEW
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). You should be able to trigger the TODO update modal.
Update the TodosUpdateModal component with the @* NEW *@
code.
- Add the
RxModalAction
component that will send the PUT request. - The model's
Id
property is included as a route parameter. This is necessary because theTodosForm
only has theTitle
andDescription
properties. If a hidden input for theId
was added to the form, the route parameter would be optional. My personal opinion is that having theId
as route parameter is better for visibility of intent.
@implements IComponentModel<TodoModel>
<div id="todo-update-modal-content">
<TodosForm Model="@(Model)" />
<div class="modal-action">
<RxModalDismiss autofocus class="btn btn-neutral">
Cancel
</RxModalDismiss>
@* NEW *@
<RxModalAction
hx-put="@($"/todos/{Model.Id}")"
hx-disabled-elt="this"
class="btn btn-primary">
Save
</RxModalAction>
@* END NEW *@
</div>
</div>
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosHandler
with the //NEW
code.
- Add the
IRequestHandler
endpoint and delegate mapping for the PUT request. This includes the{id}
route segment for the TODOId
. - The
WithRxValidation
filter is added to the route since the model must be validated for correctness as part of the update process. - The
ValidationContext
is injected into thePut
delegate. - If the
ValidationContext
contains errors, thePut
delegate responds with theTodosUpdateModal
component. - The
Put
delegate will read the TODO from the fake DbContext. If the TODO is not found because it has been deleted by a different user, theTypedResults.NotFound
is returned. This will signal htmx to throw an error, and RazorX will redirect to the error page which will force the user to navigate to a fresh TODOs page. - The response extension methods
HxRetarget
andHxReswap
are used to set response headers for htmx. These headers will override the originalhx-target
andhx-swap
attributes for the request. They may also be used if thehx-target
orhx-swap
was not specified to begin with. In this case, theRxModalAction
did not specify either thehx-target
orhx-swap
, although it could have. - The projected model is bound to the
TodosItem
component and returned in the response.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
router.MapPatch("/todos/{id}", Patch)
.AllowAnonymous();
router.MapDelete("/todos/{id}", Delete)
.AllowAnonymous();
router.MapGet("/todos/{id}", GetUpdateModal)
.AllowAnonymous();
//NEW
router.MapPut("/todos/{id}", Put)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
//NEW
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
false,
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model,
ValidationContext validationContext) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todos-new");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosNew, TodoModel>(model);
}
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
ResetForm = true,
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Patch(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NoContent();
}
todo.IsComplete = !todo.IsComplete;
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"Updated TODO to {(todo.IsComplete ? "completed" : "not completed")}."))
.Add(new HxFocusTrigger($"#todos-item-completed-{todo.Id}"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Delete(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is not null) {
FakeDbContext.Todos.Remove(todo);
}
hxTriggers
.With(response)
.Add(new HxCloseModalTrigger("#delete-todo-modal"))
.Add(new HxToastTrigger("#success-toast", $"TODO deleted."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
response.HxRetarget($"#todos-item-{id}");
response.HxReswap("outerHTML");
return TypedResults.Ok();
}
public static IResult GetUpdateModal(
HttpResponse response,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NotFound();
}
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
return response.RenderComponent<TodosUpdateModal, TodoModel>(model);
}
//NEW
public static IResult Put(
HttpResponse response,
IHxTriggers hxTriggers,
ValidationContext validationContext,
string id,
TodoModel model) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todo-update-modal-content");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosUpdateModal, TodoModel>(model);
}
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NotFound();
}
todo.Title = model.Title!;
todo.Description = model.Description!;
todo.LastUpdated = DateTime.UtcNow;
model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxCloseModalTrigger("#update-todo-modal"))
.Add(new HxToastTrigger("#success-toast", $"TODO updated."))
.Add(new HxFocusTrigger($"#todos-item-edit-{todo.Id}"))
.Build();
response.HxRetarget($"#todos-item-{id}");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosItem, TodoModel>(model);
}
//END NEW
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). You should be able to update the TODO.
Update the TodosForm component with the @* NEW *@
code.
- The
TodosForm
component only displays validation errors after a submit. Using theRxChangeValidator
will enable the user to get real-time validation feedback as they edit the form. - The
RxChangeValidator
requires form elements to have stableid
values across requests, so you must ensure theid
attribute values are computed safely and consistently. - The
ValidationPostRoute
is the POST endpoint for validating the model. - The
<input type="hidden" name="@(nameof(Model.Id))" value="@(Model.Id)">
is necessary for serialization of the modal across requests. Prior to this we passed theId
as a route parameter, but theRxChangeValidator
requires all necessary information about the model to be in the request body. - RazorX components do not participate in change validation by default, so it must be opted into by setting
AllowValidateOnChange
totrue
.
@implements IComponentModel<TodoModel>
@* NEW *@
<RxChangeValidator
Id="todos-validator"
ValidationPostRoute="/todos/validate"
IsDisabled="@(false)">
<input type="hidden" name="@(nameof(Model.Id))" value="@(Model.Id)">
@* END NEW *@
<div>
<!-- Title Field -->
<Field
Id="@($"{nameof(Model.Title)}{Model.Id}")"
PropertyName="@(nameof(Model.Title))"
Value="@(Model.Title)"
Label="Title"
InputType="text"
UseOpacityForValidationErrors="@(true)"
@* NEW *@
AllowValidateOnChange="@(true)"
@* END NEW *@
maxlength="80"
placeholder="e.g., Learn the RazorX meta-framework!">
</Field>
</div>
<div>
<!-- Description Memo Field -->
<MemoField
Id="@($"{nameof(Model.Description)}{Model.Id}")"
PropertyName="@(nameof(Model.Description))"
Value="@(Model.Description)"
Label="Description"
MaxLength="500"
UseOpacityForValidationErrors="@(true)"
@* NEW *@
AllowValidateOnChange="@(true)"
@* END NEW *@
placeholder="e.g., This includes reading the htmx documentation and checking out Tailwind and daisyUI.">
</MemoField>
</div>
@* NEW *@
</RxChangeValidator>
@* END NEW *@
@code {
[Parameter] public TodoModel Model { get; set; } = null!;
}
Update the TodosHandler
with the //NEW
code.
- Add the
IRequestHandler
endpoint andValidate
delegate mapping for the POST request. - The
WithRxValidation
filter is added to the route. - The
Validate
delegate only needs to return theTodosForm
component because theTodosValidator
validated the model before the delegate was invoked.
using Demo.Rx;
namespace Demo.Components.Todos;
public class TodosHandler : IRequestHandler {
public void MapRoutes(IEndpointRouteBuilder router) {
router.MapGet("/todos", Get)
.AllowAnonymous()
.WithRxRootComponent();
router.MapPost("/todos", Post)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
router.MapPatch("/todos/{id}", Patch)
.AllowAnonymous();
router.MapDelete("/todos/{id}", Delete)
.AllowAnonymous();
router.MapGet("/todos/{id}", GetUpdateModal)
.AllowAnonymous();
router.MapPut("/todos/{id}", Put)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
//NEW
router.MapPost("/todos/validate", Validate)
.AllowAnonymous()
.WithRxValidation<TodosValidator>();
//END NEW
}
public static IResult Get(
HttpResponse response) {
var model = FakeDbContext.Todos.Select(x => new TodoModel(
false,
x.Id,
x.Title,
x.Description,
x.IsComplete,
x.LastUpdated));
return response.RenderComponent<TodosPage, IEnumerable<TodoModel>>(model);
}
public static IResult Post(
HttpResponse response,
IHxTriggers hxTriggers,
TodoModel model,
ValidationContext validationContext) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todos-new");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosNew, TodoModel>(model);
}
var todo = new Todo {
Id = Guid.NewGuid().ToString(),
Title = model.Title!,
Description = model.Description!,
LastUpdated = DateTime.UtcNow,
IsComplete = false
};
FakeDbContext.Todos.Add(todo);
model = model with {
ResetForm = true,
Id = todo.Id,
LastUpdated = todo.LastUpdated
};
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"New TODO created."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Patch(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NoContent();
}
todo.IsComplete = !todo.IsComplete;
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxToastTrigger("#success-toast", $"Updated TODO to {(todo.IsComplete ? "completed" : "not completed")}."))
.Add(new HxFocusTrigger($"#todos-item-completed-{todo.Id}"))
.Build();
return response.RenderComponent<TodosItem, TodoModel>(model);
}
public static IResult Delete(
HttpResponse response,
IHxTriggers hxTriggers,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is not null) {
FakeDbContext.Todos.Remove(todo);
}
hxTriggers
.With(response)
.Add(new HxCloseModalTrigger("#delete-todo-modal"))
.Add(new HxToastTrigger("#success-toast", $"TODO deleted."))
.Add(new HxFocusTrigger($"#{nameof(todo.Title)}-input"))
.Build();
response.HxRetarget($"#todos-item-{id}");
response.HxReswap("outerHTML");
return TypedResults.Ok();
}
public static IResult GetUpdateModal(
HttpResponse response,
string id) {
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NotFound();
}
var model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
return response.RenderComponent<TodosUpdateModal, TodoModel>(model);
}
public static IResult Put(
HttpResponse response,
IHxTriggers hxTriggers,
ValidationContext validationContext,
string id,
TodoModel model) {
if (validationContext.Errors.Count != 0) {
response.HxRetarget("#todo-update-modal-content");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosUpdateModal, TodoModel>(model);
}
var todo = FakeDbContext.Todos.Where(x => x.Id == id).SingleOrDefault();
if (todo is null) {
return TypedResults.NotFound();
}
todo.Title = model.Title!;
todo.Description = model.Description!;
todo.LastUpdated = DateTime.UtcNow;
model = new TodoModel(
false,
todo.Id,
todo.Title,
todo.Description,
todo.IsComplete,
todo.LastUpdated);
hxTriggers
.With(response)
.Add(new HxCloseModalTrigger("#update-todo-modal"))
.Add(new HxToastTrigger("#success-toast", $"TODO updated."))
.Add(new HxFocusTrigger($"#todos-item-edit-{todo.Id}"))
.Build();
response.HxRetarget($"#todos-item-{id}");
response.HxReswap("outerHTML");
return response.RenderComponent<TodosItem, TodoModel>(model);
}
//NEW
public static IResult Validate(
HttpResponse response,
TodoModel model) {
return response.RenderComponent<TodosForm, TodoModel>(model);
}
//END NEW
}
Run the dotnet watch
command in the terminal to launch the app (or Ctrl-R
to rebuild if it is running). TODO form validation errors will display in real-time, as appropriate.
Congratulations! You have successfully completed the tutorial - Happy Coding!!!