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 — page, dialog component, and model in one file. Three [Route] attributes drive the dialog: /todos lists, /todos/new opens add, /todos/{id:guid}/edit opens edit. OnPropsChanged seeds the form from the route so browser Back closes the dialog and deep links open it, without clobbering in-progress typing.

TodosPage.cs

using System.ComponentModel.DataAnnotations;
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 Navigator _nav;
    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(Navigator nav, RouteState route, ITodoStore? store = null)
    {
        _nav = nav;
        _route = route;
        _store = store ?? new InMemoryTodoStore();
        _todos = _store.GetAll().ToList();
    }

    [RouteParam] public Guid? Id { get; set; }

    protected override Component? HeadAssets =>
        PageMeta.For(
            "Todos — Rask",
            "A full CRUD slice built from Rask components — list, add, edit and complete — with routing, forms and validation, running entirely in the browser on WebAssembly.",
            Routes.TodosPage());

    private bool IsAdding => _route.Path.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 void OnPropsChanged() => _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 void OpenAdd() => _nav.NavigateTo($"{Routes.TodosPage()}/new");

    private void OpenEdit(TodoItem item) => _nav.NavigateTo($"{Routes.TodosPage()}/{item.Id}/edit");

    private void Cancel() => _nav.NavigateTo(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);
        }

        _nav.NavigateTo(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);
    }

    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-ui-muted text-sm")[
                    $"{_todos.Count} item{(_todos.Count == 1 ? "" : "s")}, {_todos.Count(t => t.Completed)} done"
                ],
                Button.Type("button").Class(Tw.BtnPrimary).OnClick(OpenAdd)[
                    UiIcon.Name(UiIconName.Plus).Class("me-1"), "New todo"
                ]
            ],
            _todos.Count == 0
                ? Div.Class("text-ui-muted text-sm")["No todos yet — click \"New todo\" to add one."]
                : Ul.Id("todo-list")
                    .Class("divide-y divide-ui-line rounded-lg ring-1 ring-ui-line")[
                    _todos.Select(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. Bootstrap Icons carried no
                        // name either -- the label is what the icon was always standing in for.
                        Button.Type("button").Class(Tw.BtnOutlineSecondary)
                            .Aria(new Dictionary<string, string?> { ["label"] = $"Edit {item.Title}" })
                            .OnClick(() => OpenEdit(item))[
                            UiIcon.Name(UiIconName.Pencil)
                        ],
                        Button.Type("button").Class(Tw.BtnOutlineDanger)
                            .Aria(new Dictionary<string, string?> { ["label"] = $"Delete {item.Title}" })
                            .OnClick(() => Delete(item))[
                            UiIcon.Name(UiIconName.Trash)
                        ]
                    ])
                ],
            CodeSample
                .Files(["TodosPage.cs"])
                .Title("Source")
                .Notes("The whole CRUD screen above, verbatim — page, dialog component, and model in one file. " +
                "Three [Route] attributes drive the dialog: /todos lists, /todos/new opens add, " +
                "/todos/{id:guid}/edit opens edit. OnPropsChanged 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)
        ];
}

public sealed partial class TodoFormDialog : Component
{
    // Non-nullable props with no initializer → the generator emits them as required positional factory
    // parameters (RASK001); Rask's post-render assignment satisfies them, so CS8618 here is intentional.
    // The dialog is driven entirely by route state, so no IJSRuntime/ElementRef is
    // needed — the whole dialog is one composed component tree with no lifecycle plumbing.
#pragma warning disable CS8618
    public bool Open { get; set; }
    public TodoForm Model { get; set; }
    public bool IsAdding { get; set; }
    public Action OnCancel { get; set; }
    public Action<TodoForm> OnSave { get; set; }
#pragma warning restore CS8618

    // OnClose fires for Escape, a backdrop click, and the header close button — all route back to /todos
    // via OnCancel, which flips ShowDialog and closes the modal. Browser Back does the same through the URL.
    // Template is required, so it is the chain's opening step: a validation message with no way to
    // render itself is not a thing the type system lets you ask for.
    private static Component FieldError(IReadOnlyList<string> errors) =>
        Div.Class("field-error text-sm text-ui-danger")[errors.Select(e => Div.Key(e)[e])];

    protected override Component? Render() =>
        // The native <dialog>. BsModal supplied a backdrop, Escape-to-dismiss and a focus trap. A
        // <dialog> rendered with the `open` attribute is NON-modal, so it supplies none of the three —
        // showModal() would, but it needs JS. The first two are cheap to keep as Rask state and a
        // dialog without them is a worse dialog, so they are rebuilt below. The true focus TRAP (tab
        // cannot leave) is the part that genuinely needs showModal, and is the honest reduction.
        [
            // A non-modal <dialog open> paints no backdrop of its own, so without this there is nothing
            // dimming the page and nothing to click outside the dialog. It carries the click that cancels.
            !Open
                ? null
                : Div.Class("dialog-backdrop fixed inset-0 z-40 bg-black/40").OnClick(() => OnCancel?.Invoke()),
            Dialog.Open(Open).Class(
                "fixed inset-0 z-50 m-auto h-fit w-full max-w-md rounded-xl bg-ui-bg p-5 shadow-xl")
                // Escape dismisses. A non-modal dialog fires no `cancel` event, so the key is read
                // where it lands — no client script, just the same routed cancel the backdrop uses.
                .OnKeyDown(e =>
                {
                    if (e.Key == "Escape")
                    {
                        OnCancel?.Invoke();
                    }
                })[
                H2.Class("mb-3 text-lg font-semibold")[IsAdding ? "Add todo" : "Edit todo"],
                Form.Model(Model).OnValidSubmit(OnSave).Class("flex flex-col gap-3")[
                    Label.For("todo-title").Class("text-sm font-medium")["Title"],
                    // autofocus fires when the browser PARSES the element -- a deep link to /todos/new
                    // lands in the field. Opening the dialog through the live diff inserts it after
                    // parse, where browsers ignore the attribute, so that path still needs a click.
                    // Reliable focus-on-open would need ElementRef + IJSRuntime; this page is a routed
                    // CRUD flow, not a dialog implementation.
                    Input.Bind(() => Model.Title).Id("todo-title").Autofocus(true).Class(Tw.Input),
                    ValidationMessage.Template(FieldError).For(() => Model.Title),
                    Div.Class("flex justify-end gap-2")[
                        Button.Type("button").Class(Tw.BtnOutlineSecondary).OnClick(OnCancel)["Cancel"],
                        Button.Class(Tw.BtnPrimary).Type("submit")[
                            UiIcon.Name(UiIconName.CheckCircle).Class("me-1"),
                            IsAdding ? "Add" : "Save"
                        ]
                    ]
                ]
            ]
        ];
}

public sealed class TodoItem
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public string Title { get; set; } = "";
    public bool Completed { get; set; }
}

public sealed class TodoForm
{
    [Required(ErrorMessage = "Title is required.")]
    [StringLength(120, MinimumLength = 1, ErrorMessage = "Title must be 1–120 characters.")]
    public string Title { get; set; } = "";
}
Live result

Edit todo