Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Panel and routing

A Panel is an admin panel: resources and pages under one prefix, one shell, one login. You configure it with builder calls and mount it on your app’s Topcoat router, which serves every registered resource and page, the login page, and the shell that frames them, beside your own routes.

#![allow(unused)]
fn main() {
use tablo::prelude::*;
use topcoat::{asset::RouterBuilderAssetExt, router::{Router, RouterBuilderDiscoverExt}};

pub fn admin_router(db: Db) -> topcoat::Result<Router> {
    let router = Router::builder()
        .discover() // the app's own pages and routes, and Tablo's
        .app_context(db) // the toasty::Db every handler uses
        .panel(
            Panel::new("admin") // mounted at /admin
                .brand(Brand::new("Acme"))
                .home::<Dashboard>() // GET /admin
                .resource::<UserResource>() // /admin/users and its sub-routes
                .page::<ReportsPage>(), // GET /admin/reports
        )?
        .build();
    Ok(router)
}
}

Routes

A resource with the slug users, on a panel mounted at /admin, serves:

MethodPathServes
GET/admin/usersthe list
GET/admin/users/{id}the detail page; 404 when the resource’s view is empty
POST/admin/users/{id}/deletedelete one record
POST/admin/users/bulk-deletedelete the selected records
GET/admin/users/exportthe list as CSV
GET, POST/admin/users/createthe create form ¹
GET, POST/admin/users/{id}/editthe edit form ¹
GET/admin/users/optionsoption search for a searchable relationship select ¹

¹ Only for a resource whose Form is a record form; a list-only resource names NoForm and gets none of these. See Forms.

{id} is the record’s primary key. Each route checks the resource’s policy, so a registered route is not an open one: see Policy, auth, tenancy.

The panel also serves:

  • GET /admin: the home page, or a redirect to the first registered resource’s list when there is none.
  • GET, POST /admin/login and POST /admin/logout, unless authentication is disabled.

Builder reference

CallEffectSee
Panel::new(prefix)the panel at /{prefix} ("" mounts at /admin)
resource::<R>()registers a resource’s routes and sidebar entryResources
page::<P>()registers a page at /{prefix}/{slug}Pages
home::<P>()registers a page at /{prefix}Pages
brand(Brand)sets the name and optional logo in the sidebar, the topbar and the login cardBranding
dark_mode(bool)sets the theme a first-time visitor seesBranding
login_hint(text)adds a line under the login formBranding
auth(Auth)replaces or disables the built-in password loginPolicy, auth, tenancy
uploads(uploader)sets where file fields store their bytesForms
serve_dir(path, dir)serves a directory of files, publiclyForms
shell_assets(css, font)adds the stylesheet, font and scriptsAssets
layout(render)frames the panel’s pages with your layout instead of the shellThe shell layout
frame_ancestors(..), without_frame_ancestors()changes the anti-framing headerSecurity

Mounting

Router::builder().panel(panel) mounts a panel; the RouterBuilderPanelExt trait, in the prelude, provides it. The router is yours: discover your own pages and routes, install the Db with .app_context(db) and the asset bundle with .assets(..), then mount. The panel reads both from the router, so they come first.

The first panel mounted also installs what every panel shares: cookies, sessions unless the router already configures them, the gate over Topcoat’s runtime endpoints with the shard dispatch, and Topcoat’s runtime layer with link prefetching off unless the router already set those up. The runtime layer has no path, so mount panels after your own pathless layers: a page re-run must reach those layers already rewritten to a GET.

The builder calls never fail; .panel(..) returns a MountError listing every mistake instead. It refuses a router with no Db, a Db missing the shipped auth models, two resources or pages with one slug, a slug the panel routes itself (login, logout), a slug that is not a single URL segment, a second home page, shell_assets on a router with no asset bundle, a prefix that overlaps another panel’s or Topcoat’s /_topcoat/runtime, and every resource declaration check described in Resources.

Several panels

One router mounts any number of panels at distinct prefixes, each with its own resources, pages, sidebar, brand and auth:

#![allow(unused)]
fn main() {
pub fn two_panels(db: Db) -> topcoat::Result<Router> {
    let router = Router::builder()
        .discover()
        .app_context(db)
        .panel(
            Panel::new("admin")
                .resource::<UserResource>()
                .resource::<OrderResource>(),
        )?
        .panel(
            Panel::new("portal")
                .brand(Brand::new("Customer portal"))
                .auth(Auth::custom(Customers))
                .resource::<OrderResource>(),
        )?
        .build();
    Ok(router)
}
}

A resource registered by two panels is declared once: both serve the same table, form and policy, each under its own prefix. A panel’s prefix must not overlap another’s: /admin and /admin/reports are refused, /admin and /administration are not.

Each panel has its own login page, and a session belongs to the panel that signed the user in. A user signed in to /admin is anonymous on /portal, and signing in to /portal ends the /admin session. See Policy, auth, tenancy.

URLs

The tablo::url helpers answer for the request’s panel, so app code never spells a prefix:

#![allow(unused)]
fn main() {
pub fn panel_urls(cx: &Cx) {
    let _ = tablo::url::resource::<PostResource>(cx); // Some("/admin/posts")
    let _ = tablo::url::page::<ReportsPage>(cx); // Some("/admin/reports")
    let _ = tablo::url::panel(cx); // Some("/admin")
}
}

The request’s panel is the one whose prefix the request is under; on a router with a single panel it is that panel for every request. A resource or page that panel does not register has no URL there, and the helper returns None. tablo::panel::navigation::<R>(cx) answers the same way with R’s whole sidebar entry, its label, URL and icon, which suits a dashboard linking to lists.

The shell layout

The panel frames every page under its prefix in the shell — sidebar, topbar, theme toggle and notification toasts — with a layout it registers itself. Declare no #[layout] of your own at the prefix: a second layout there would nest a second document inside the first.

To change the frame, pass your own layout function to Panel::layout. Panel::layout_shell is the shipped one, so a layout that keeps the shell calls it around its own markup; one that does not call it replaces the shell entirely:

#![allow(unused)]
fn main() {
pub fn admin_layout<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a> {
    let page = view! { cx => <div class="acme-admin">(slot)</div> }.boxed();
    Panel::layout_shell(cx, page.into())
}

pub fn shell_panel() -> Panel {
    Panel::new("admin").layout(admin_layout)
}
}

Every resource and page gets one sidebar entry, labelled with its plural label (a page’s navigation_label()) and linked to its list or page URL. Entries sort by order (lower first, default 0); entries with the same order keep registration order, and the home page leads its order.

A resource sets the order and an icon on its def, and the panel resolves the URL:

#![allow(unused)]
fn main() {
impl Resource for UserResource {
    // …
    fn declare() -> ResourceDef<Self> {
        ResourceDef::new()
            // …
            .navigation_order(-1)
            .icon(tablo_ui::icons::USERS)
    }
}
}

A page overrides Page::navigation(), starting from NavigationItem::for_page::<Self>(). NavigationItem::at(label, url) links elsewhere, from a resource’s ResourceDef::navigation or a page’s navigation(); the panel keeps that URL as written. The entry whose URL is the longest match for the current path is marked active.

Pages

A page that is not a record list — a dashboard, a report, a settings screen — implements Page:

#![allow(unused)]
fn main() {
use tablo_core::{Page, Panel};
use topcoat::{Result, context::Cx, view::{View, view}};

pub struct ReportsPage;

impl Page for ReportsPage {
    async fn render(cx: &Cx) -> Result<impl View> {
        Ok(view! {
            cx =>
            tablo_ui::page(
                tablo_ui::page_header(tablo_ui::page_title("Reports"))
                tablo_ui::page_content(tablo_ui::card(tablo_ui::card_content("…")))
            )
        })
    }
}

pub fn reports_panel() -> Panel {
    Panel::new("admin").page::<ReportsPage>() // GET /admin/reports
}
}
  • Slug and label default to the type name without its Page suffix: ReportsPage mounts at reports and is labelled “Reports”; MediaLibraryPage mounts at media-library and is labelled “Media library”. Override slug() and navigation_label() to change them. Pages and resources share one slug namespace.
  • The home page. Panel::home::<P>() mounts a page at the prefix itself instead of the redirect to the first resource. A panel has at most one; a second fails .panel(..).
  • Access. With authentication on, the page renders only for a signed-in user with panel access, inside the shell layout.
  • Forms. A page serves one GET. A form it renders posts to an app #[route]; put that route under the panel prefix so the auth gate covers it.

The tablo_ui composites give a page the same frame as the panel’s own pages: page sets the width and padding; page_header holds a page_title, an optional page_description and optional page_actions; page_content holds the body. Inside it, use card for a panel of content and empty_state for a region with nothing to show. They are styled by the design tokens in your styles.css (--card, --border, --primary, …), so one theme restyles the panel’s pages and yours.

Public pages

A page outside the panel prefix is an ordinary Topcoat #[page] on your router. The auth gate covers only the panel prefix and /_topcoat/runtime, so such a page is public.

Panel::document renders the same HTML document as the admin shell — head, assets, theme — around your own markup. Outside any prefix it takes the panel’s shell settings when the router mounts one panel; with several, it renders the document without the panel’s stylesheet and font.

#![allow(unused)]
fn main() {
// A layout wraps every route under its path: this one wraps /blog and /blog/{id}.
// A layout at "/" would wrap /admin too.
#[layout("/blog")]
pub async fn blog_layout(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
    Panel::document(
        cx,
        "Blog",
        view! { <div class="mx-auto max-w-3xl px-6 py-10">(slot)</div> },
    )
    .await
}

#[page("/blog")]
pub async fn blog() -> Result<impl View> {
    Ok(view! { "Posts" })
}
}

A public page loads its rows with a plain Toasty query, not through a resource. The resource’s tenant scope applies only to the panel’s loaders, so in a multi-tenant app the page states its own tenant predicate. See Data access.

Branding and theme

#![allow(unused)]
fn main() {
pub fn branded_panel() -> Panel {
    Panel::new("admin")
        .brand(Brand::new("Acme").logo("/logo.svg"))
        .dark_mode(true)
        .login_hint("Demo: admin@example.com / password")
}
}
  • brand names the panel in the sidebar header, on the login card, and in the topbar on narrow screens. A brand without a logo shows its first letter.
  • dark_mode(true) makes the panel start dark for a visitor who has not chosen a theme. The theme toggle always renders, and a visitor’s choice, light or dark, is remembered and wins over this default. Without the call the panel starts light.
  • login_hint renders a muted line under the login form, such as demo credentials.

Assets

Without assets the panel renders unstyled HTML without the shell’s scripts, and every page and form still works. To style it, install the app’s Topcoat asset bundle on the router, then give the panel the Tailwind stylesheet and the font the shell links:

#![allow(unused)]
fn main() {
pub const GEIST: Font = fontsource_font!(GEIST, host: Asset);

pub fn asset_panel(db: Db, bundle: AssetBundle) -> Panel {
    let _router = Router::builder().discover().app_context(db).assets(bundle);
    Panel::new("admin").shell_assets(tailwind::stylesheet!(), GEIST)
}
}

shell_assets also makes the shell load tablo-ui’s scripts: live search, confirmation dialogs, searchable selects, toasts and the sidebar and theme toggles. Mounting refuses shell_assets on a router with no asset bundle. The stylesheet comes from tablo_build::tailwind() in the app’s build.rs (Your first panel).