Todos
A small CRUD screen built on top of Rask primitives. The page declares three [Route] attributes — /todos shows the list, /todos/new opens the add dialog, /todos/{id:guid}/edit opens the edit dialog. Browser Back closes the dialog; deep links open it.
2 items, 1 done
- Read the Rask README
- Wire up a feature toggle
Source
The whole CRUD screen above, verbatim — the page, its dialog component, and the models, a file each. Three [Route] attributes drive the dialog: /todos lists, /todos/new opens add, /todos/{id:guid}/edit opens edit. Updated seeds the form from the route so browser Back closes the dialog and deep links open it, without clobbering in-progress typing.
using Rask.Core.Routing;
namespace Rask.Site.Features;
[Route("todos")]
[Route("todos/new")]
[Route("todos/{id:guid}/edit")]
[ParentRoute(typeof(ShowcaseLayout))]
public sealed partial class TodosPage : Component
{
private readonly RouteState _route;
private readonly TodoForm _form = new();
// Persistence seam: the injected ITodoStore, or a throwaway seeded in-memory store when none is
// registered (Server/WASM showcase). An app can register a durable store — e.g. SQLite-backed — and the
// same screen then persists across a restart. _todos is the render working set, written through to the
// store on every change.
private readonly ITodoStore _store;
private readonly List<TodoItem> _todos;
public TodosPage(RouteState route, ITodoStore? store = null)
{
_route = route;
_store = store ?? new InMemoryTodoStore();
_todos = _store.GetAll().ToList();
}
[RouteParam] public Guid? Id { get; set; }
protected override Component? HeadAssets =>
PageMeta.For(
"C# CRUD example with routing and forms — Rask",
"A to-do list CRUD example in C#: list, add, edit, complete and delete, with route-driven "
+ "dialogs, form validation and the full source, running in WebAssembly.",
Routes.TodosPage());
// Trimmed first: the host serves /docs/todos/new/, which is the path a reload — and now a link — arrives
// with, and "/new/" does not end with "/new".
private bool IsAdding => _route.Path.TrimEnd('/').EndsWith("/new", StringComparison.OrdinalIgnoreCase);
private TodoItem? EditingItem =>
Id is { } id ? _todos.FirstOrDefault(t => t.Id == id) : null;
private bool ShowDialog => IsAdding || EditingItem is not null;
// Fires on first render, on any [RouteParam] change, AND on URL-path change for the
// same cached page instance (the framework OR's path change into propsChanged inside
// RouteChainRenderer). Bare re-renders triggered by event handlers don't refire it,
// so typing in the dialog input won't clobber what the user just typed.
protected override async Task OnUpdated() => _form.Title = EditingItem?.Title ?? "";
// The list route has a generated type-safe URL; the /new and /{id}/edit dialog routes are secondary
// [Route] templates on this same page, and the generator emits no formatter for those — so they are
// built by appending to the generated one rather than written out.
//
// They used to be the literals "/todos/new" and "/todos/{id}/edit", which was fine while this page
// sat at the app root. Under a [ParentRoute] the real URL is /docs/todos/new, so the literals
// navigated to a path with no route behind it: the dialog never opened and the page rendered the
// 404. Deriving them from Routes.TodosPage() keeps the secondary templates pinned to the primary.
private static void OpenAdd() => Go.To($"{Routes.TodosPage()}/new");
private static void OpenEdit(TodoItem item) => Go.To($"{Routes.TodosPage()}/{item.Id}/edit");
private static void Cancel() => Go.To(Routes.TodosPage());
// Every mutation is written through to the store, so a SQLite-backed store (native) persists it.
private void Save(TodoForm m)
{
var title = m.Title.Trim();
if (IsAdding)
{
var item = new TodoItem { Title = title };
_todos.Add(item);
_store.Add(item);
}
else if (EditingItem is { } item)
{
item.Title = title;
_store.Update(item);
}
Go.To(Routes.TodosPage());
}
private void Toggle(TodoItem item, bool completed)
{
item.Completed = completed;
_store.Update(item);
}
private void Delete(TodoItem item)
{
_todos.Remove(item);
_store.Delete(item.Id);
}
private Component TodoRow(TodoItem item) =>
Li
.Key(item.Id)
.Class("flex items-center gap-2 px-3 py-2")[
// Input derives type="checkbox" from the bool it is given — there is no
// separate checkbox control to reach for.
Input
.Value(item.Completed)
.OnChange(v => Toggle(item, v))
.Id($"todo-done-{item.Id}")
.Class("size-4"),
Span.Class(item.Completed ? "todo-title completed" : "todo-title")[item.Title],
// Icon-only, so the glyph is the whole button: without an accessible name a
// screen reader announces "button" and nothing else. An icon carries no
// name of its own -- the label is what it stands in for.
// The Aria step is gone because the Label IS the accessible name here: a
// square button holds one glyph, so Ui.Button writes the label as aria-label
// rather than as visible text.
Ui.Button.Icon(Ui.IconName.Pencil)
.AriaLabel($"Edit {item.Title}")
.OnClick(() => OpenEdit(item)),
Ui.Button.Red.Icon(Ui.IconName.Trash)
.AriaLabel($"Delete {item.Title}")
.OnClick(() => Delete(item))
];
protected override Component? Render() =>
[
PageHeader
.Title("Todos")
.Lead("A small CRUD screen built on top of Rask primitives. The page declares three [Route] attributes — /todos shows the list, /todos/new opens the add dialog, /todos/{id:guid}/edit opens the edit dialog. Browser Back closes the dialog; deep links open it."),
Div
.Class("flex justify-between items-center mb-3")[
Span.Class("text-zinc-500 dark:text-white/70 text-sm")[
$"{_todos.Count} item{(_todos.Count == 1 ? "" : "s")}, {_todos.Count(t => t.Completed)} done"
],
Ui.Button.Primary.Icon(Ui.IconName.Plus).OnClick(OpenAdd)["New todo"]
],
_todos.Count == 0
? Div.Class("text-zinc-500 dark:text-white/70 text-sm")["No todos yet — click \"New todo\" to add one."]
: Ul.Id("todo-list")
.Class("divide-y divide-zinc-200 dark:divide-white/10 rounded-lg ring-1 ring-zinc-200 dark:ring-white/10")[
_todos.Select(TodoRow)
],
CodeSample
.Files(["TodosPage.cs", "TodoFormDialog.cs", "TodoItem.cs", "TodoForm.cs"])
.Title("Source")
.Notes("The whole CRUD screen above, verbatim — the page, its dialog component, and the models, a file each. " +
"Three [Route] attributes drive the dialog: /todos lists, /todos/new opens add, " +
"/todos/{id:guid}/edit opens edit. Updated seeds the form from the route so browser " +
"Back closes the dialog and deep links open it, without clobbering in-progress typing."),
// A dialog driven by the route: Open follows ShowDialog, and
// Escape / backdrop-click / the header close button all route back to /todos via OnCancel.
TodoFormDialog
.Open(ShowDialog)
.Model(_form)
.IsAdding(IsAdding)
.OnCancel(Cancel)
.OnSave(Save)
];
}Live result