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
Demofolder. - Run the
dotnet new razorxcommand in a terminal in theDemofolder to spawn a new RazorX application. - Run the
dotnet watchcommand 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-Cin the terminal.
- Create a new
Todosfolder underComponents. - Create a new
TodosPage.razorfile. - Create a new
TodosHandler.csfile.
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
WithRxRootComponentfilter indicates that theRazorComponentResultshould be embedded in the layoutIRootComponent. TheUseRxRoutercall inProgram.csspecifies 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
EntityFrameworkfor 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 aQuantityproperty. 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. Therequiredelement 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
recordtype for immutability, butclassandstructmay 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
TitleandDescriptionproperties. - This fragment will be used in the
TodosNewandTodosUpdateModalcomponents, 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
Idparameters map to HTML Elementidattributes and must be unique. If anIdis not set, RazorX will generate a unique value for the element. However, in many cases theIdwill need to be a known value for htmxhx-targetattributes or JavaScriptgetElementByIdoperations. In these cases, theIdmust be computed and assigned. - The
TodosFormcomponent uses the RazorXFieldcomponent for theTitleproperty binding and theMemoFieldcomponent for theDescriptionproperty binding. - All RazorX components will use the assigned
{Id}-inputformat for the underlying HTML input elementid. - The
PropertyNameparameter is what binds the component to the model's property. ThePropertyNametranslates to the HTML input element'snameattribute, which is the key used for the key-value pair on form submission. For easier refactoring, it is recommended to use thenameofexpression to set thePropertyNameinstead of a hard-coded string. Components/Rxcontains many pre-built components to accelerate development. They are separated intoHeadlessandSkinned.Headlesscomponents implement structure and behavior, andSkinnedcomponents wrap associatedHeadlesscomponents 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
TodosFormcomponent 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-posttells htmx to issue a POST request to the/todosendpoint on form submission. - The
hx-targetis the element that will be targeted in the response. In this case, the#means use theidto identify the element. CSS selector syntax is used by htmx for element identification. - The
hx-swapdefines the swap strategy htmx should use to place the response in the targeted element. Most often this will beinnerHTML(the default) orouterHTMLto replace the entire element. In this case, thebeforeendstrategy 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
RxUtcToLocalcomponent 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
TodosNewcomponent is passed a newTodoModelsince 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
Getis updated to query the fake DbContext and project list ofTodoModels. - 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
IdandLastUpdatedproperties before binding it to the component in the response. - The
IHxTriggersbuilder is injected into the POST delegate.HxTriggersare 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 theTitleinput element is focused. - The handlers for the
IHxTriggersdispatched events are located in thewwwroot/js/razorx.jsfile, but no customization should be needed for any implementations in this file.IHxTriggersis 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
ResetFormflag 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
TodosItemcomponent is returned after a successful POST call to add a new TODO. - The model's
ResetFormflag is evaluated to see if a newTodosNewcomponent 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 theTodosNewcomponent'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
Getdelegate with theResetFormflag set to false. - Update the
Postdelegate with theResetFormflag set to true. This will include theTodosNewcomponent in theTodosItemresponse 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
ValidationContextobject. - The
ValidationContextmay be injected anywhere it is needed, including theIRequestHandlerdelegates 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
TitleandDescriptionproperties 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 aValidatorwill trigger validation. - Validation occurs before the
IRequestHandlerdelegate is invoked. - The
ValidationContextis injected into thePostdelegate. - If the
ValidationContextcontains errors, thePostdelegate responds with theTodosNewcomponent instead of theTodosItemcomponent. - All RazorX components inject the
ValidationContextand have built-in validation error display styling. - The response extension methods
HxRetargetandHxReswapare used to set response headers for htmx. These headers will override the originalhx-targetandhx-swapattributes for the request. They may also be used if thehx-targetorhx-swapwas 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
Checkboxcomponent is added to allow the user to mark the TODO as complete. It is bound to the model'sIsCompleteproperty. - The
hx-patch,hx-target, andhx-swapattributes are added directly to theCheckbox. Thehx-triggerattribute 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 theonchangeevent. - When the
Checkboxis checked, htmx will send a PATCH request to the endpoint, including theIdof 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
IRequestHandlerendpoint and delegate mapping for the PATCH request. This includes the{id}route segment for the TODOId. - The
Patchdelegate reads the todo entity from the fake DbContext, updates theIsCompleteproperty, and projects a new todo model for binding to theTodosItemcomponent 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 Syncexample 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
RxModalTriggercomponent to display a modal confirmation dialog. RxModalTriggers are HTML button elements.- The
ModalIdis theidattribute value of thedialogelement. - The
RouteValueis the value to append to thehx-request route. - The
TextNodeValueis 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
dialogelement to the page and assign it theidspecified in theRxModalTrigger'sModalId. - The
RxModalTextNodeis used as the modal's title. - The
RxModalDismisscomponent is a button that will close the modal. - The
RxModalActioncomponent is a button that can issue anhx-request. In this case, the request is a DELETE to the/todosendpoint. TheRouteValueset on theRxModalTriggerwill be appended to the endpoint, so the final endpoint isDELETE: todos/{id}. - The
hx-disabled-eltattribute will disable the button while the request is in-flight. - The
RxModalActioncomponent 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
IRequestHandlerendpoint and delegate mapping for the DELETE request. This includes the{id}route segment for the TODOId. - The
Deletedelegate 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
HxCloseModalTriggeris added to the response to signal the close of the modal. - The
HxRetargetandHxReswapextension methods are used to specify theouterHTMLof the TODO item being deleted. These attributes could have been applied to theRxModalActioncomponent 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
RxModalTriggercomponent to display a modal edit dialog. RxModalTriggers are HTML button elements.- The
ModalIdis theidattribute value of thedialogelement. - The
RouteValueis the value to append to thehx-request route. - The
TextNodeValueis 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
dialogelement to the page and assign it theidspecified in theRxModalTrigger'sModalId. - The
RxModalTextNodeis used as the modal's title. - The
RxModalAsyncContentcomponent enables the modal content to be asynchronously rendered from the server. - The
RenderFromRouteis the GET endpoint. - When the
RxModalTriggertriggers the opening of the modal, theRenderFromRouteendpoint will have theRouteValueappended 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
Idof theRxModalAsyncContentcomponent and the content component's container elementidattribute must be the same value. - This component will embed the existing
TodosFormcomponent 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
IRequestHandlerendpoint and delegate mapping for the GETGetUpdateModalrequest. This includes the{id}route segment for the TODOId. - The
GetUpdateModaldelegate will read the TODO from the fake DbContext. If the TODO is not found because it has been deleted by a different user, theTypedResults.NotFoundis 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
TodosUpdateModalcomponent 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
RxModalActioncomponent that will send the PUT request. - The model's
Idproperty is included as a route parameter. This is necessary because theTodosFormonly has theTitleandDescriptionproperties. If a hidden input for theIdwas added to the form, the route parameter would be optional. My personal opinion is that having theIdas 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
IRequestHandlerendpoint and delegate mapping for the PUT request. This includes the{id}route segment for the TODOId. - The
WithRxValidationfilter is added to the route since the model must be validated for correctness as part of the update process. - The
ValidationContextis injected into thePutdelegate. - If the
ValidationContextcontains errors, thePutdelegate responds with theTodosUpdateModalcomponent. - The
Putdelegate will read the TODO from the fake DbContext. If the TODO is not found because it has been deleted by a different user, theTypedResults.NotFoundis 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
HxRetargetandHxReswapare used to set response headers for htmx. These headers will override the originalhx-targetandhx-swapattributes for the request. They may also be used if thehx-targetorhx-swapwas not specified to begin with. In this case, theRxModalActiondid not specify either thehx-targetorhx-swap, although it could have. - The projected model is bound to the
TodosItemcomponent 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
TodosFormcomponent only displays validation errors after a submit. Using theRxChangeValidatorwill enable the user to get real-time validation feedback as they edit the form. - The
RxChangeValidatorrequires form elements to have stableidvalues across requests, so you must ensure theidattribute values are computed safely and consistently. - The
ValidationPostRouteis 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 theIdas a route parameter, but theRxChangeValidatorrequires 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
AllowValidateOnChangetotrue.
@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
IRequestHandlerendpoint andValidatedelegate mapping for the POST request. - The
WithRxValidationfilter is added to the route. - The
Validatedelegate only needs to return theTodosFormcomponent because theTodosValidatorvalidated 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!!!