§10 - one menu everywhere (buildPluginChrome) + shell on every page; instructional starter dashboard; Kratos-native email docs
Collapse the three nav builders into buildPluginChrome: chrome.bestHref does longest-prefix matching so deep admin routes mark their leaf. Delete adminNav; buildConfirmModel and the admin model builders take the resolved nav. The same role-filtered sidebar now renders signed in or out, collapsing to a burger on narrow screens. shell.ejs gains menu (default true; menu:false -> single-column .app-bare), docTitle (separate <title> from the topbar, so the body keeps the single <h1>), and hideSignIn (suppress the footer Sign-in on auth pages to avoid a login loop). auth/home/landing now render inside the shell. Dashboard is a replaceable instructional starter (definePlugin snippet, no mock data). Email stays delegated to Kratos: documented its built-in courier.template_override_path instead of adding web-side SMTP.
This commit is contained in:
+19
-105
@@ -1,114 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { buildDashboardModel } from "./dashboard.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
|
||||
// Pull the first data-table column ("Name") and the rendered row count for terse assertions.
|
||||
const rowCount = (m: ReturnType<typeof buildDashboardModel>): number => m.table.rows.length;
|
||||
const nameOf = (m: ReturnType<typeof buildDashboardModel>, i: number): string =>
|
||||
// first cell is the user cell { user: { name } }
|
||||
((m.table.rows[i] as { cells: unknown[] }).cells[0] as { user: { name: string } }).user.name;
|
||||
const col0 = (m: ReturnType<typeof buildDashboardModel>) =>
|
||||
m.table.columns[0] as { href?: string; label: string; sort?: string };
|
||||
// The default /dashboard is an instructional starter (todo §10): no mock data, just the unified
|
||||
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
|
||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
||||
|
||||
test("dashboard default: page 1, mock data, nav + shell wired", () => {
|
||||
const m = buildDashboardModel(new URL("http://x/"));
|
||||
|
||||
assert.equal(m.shell.title, "People");
|
||||
assert.equal(m.shell.csrfToken, ""); // default empty; app.ts passes the per-request token
|
||||
assert.equal(buildDashboardModel(new URL("http://x/"), [], undefined, "tok.sig").shell.csrfToken, "tok.sig");
|
||||
assert.ok(m.nav.length > 0); // composeNav produced a tree
|
||||
assert.equal(col0(m).label, "Name");
|
||||
assert.equal(m.pagination.summary.total, 30); // full mock dataset
|
||||
assert.equal(m.pagination.summary.from, 1);
|
||||
assert.equal(rowCount(m), 12); // default page size
|
||||
assert.equal(m.pagination.prev.href, undefined); // first page → prev disabled
|
||||
assert.ok(m.pagination.next.href); // more pages → next enabled
|
||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
||||
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
|
||||
assert.equal(m.shell.title, "Dashboard");
|
||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
||||
assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
|
||||
});
|
||||
|
||||
test("dashboard search filters rows, shrinks the total and shows a pill", () => {
|
||||
const first = nameOf(buildDashboardModel(new URL("http://x/")), 0); // e.g. "Avery Kline"
|
||||
const m = buildDashboardModel(new URL(`http://x/?q=${encodeURIComponent(first)}`));
|
||||
test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
|
||||
const m = buildDashboardModel();
|
||||
assert.deepEqual(m.nav, []);
|
||||
assert.equal(m.shell.csrfToken, "");
|
||||
assert.equal(m.shell.user.name, "Guest");
|
||||
assert.equal(m.shell.brand.name, "Plainpages");
|
||||
|
||||
assert.equal(m.pagination.summary.total, 1);
|
||||
assert.equal(rowCount(m), 1);
|
||||
assert.equal(nameOf(m, 0), first);
|
||||
assert.deepEqual(m.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// A no-match query yields an empty table, not an error.
|
||||
const none = buildDashboardModel(new URL("http://x/?q=zzz-no-such-person"));
|
||||
assert.equal(none.pagination.summary.total, 0);
|
||||
assert.equal(rowCount(none), 0);
|
||||
});
|
||||
|
||||
test("dashboard sorts by a column, reflects direction, and the header toggles", () => {
|
||||
const asc = buildDashboardModel(new URL("http://x/?sort=name"));
|
||||
const desc = buildDashboardModel(new URL("http://x/?sort=-name"));
|
||||
|
||||
// asc first name ≤ desc first name (reverse order).
|
||||
assert.ok(nameOf(asc, 0) <= nameOf(asc, 1));
|
||||
assert.ok(nameOf(desc, 0) >= nameOf(desc, 1));
|
||||
|
||||
// The Name column carries the current direction and its header links to the opposite.
|
||||
assert.equal(col0(asc).sort, "asc");
|
||||
assert.match(col0(asc).href ?? "", /sort=-name/); // asc → click flips to desc
|
||||
assert.equal(col0(desc).sort, "desc");
|
||||
assert.match(col0(desc).href ?? "", /sort=name(?!\w)/); // desc → click flips to asc
|
||||
|
||||
// An unknown sort field is ignored (no crash, no sort indicator).
|
||||
const bad = buildDashboardModel(new URL("http://x/?sort=bogus"));
|
||||
assert.equal(col0(bad).sort, undefined);
|
||||
});
|
||||
|
||||
test("dashboard applies the central menu config: branding + nav override (rename/hide)", () => {
|
||||
const m = buildDashboardModel(new URL("http://x/"), [], {
|
||||
branding: { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin", theme: "dark" },
|
||||
override: { hide: ["teams"], rename: { people: "Staff" } },
|
||||
});
|
||||
|
||||
assert.deepEqual(m.shell.brand, { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin" });
|
||||
assert.equal(m.shell.theme, "dark");
|
||||
const labels = m.nav.map((n) => n.label);
|
||||
assert.ok(labels.includes("Staff")); // "People" renamed
|
||||
assert.ok(!labels.includes("Teams")); // "Teams" hidden
|
||||
});
|
||||
|
||||
// The dashboard assembles its nav from two gated sources — the built-in Admin section and each
|
||||
// discovered plugin's fragment (§7) — and role-filters both via composeNav. One contract: a section
|
||||
// shows only for a holder of its permission, and each source is gated independently.
|
||||
test("dashboard role-filters the gated Admin section and plugin fragments, each independently", () => {
|
||||
const plugin = {
|
||||
apiVersion: "1.0.0", id: "scheduling",
|
||||
nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], icon: "i-cal", id: "scheduling", label: "Scheduling" }],
|
||||
};
|
||||
const section = (m: ReturnType<typeof buildDashboardModel>, label: string) => m.nav.find((n) => n.label === label);
|
||||
|
||||
// An admin sees the Admin section (the four built-in screens) but not the plugin's gated section.
|
||||
const admin = buildDashboardModel(new URL("http://x/"), ["admin"], undefined, "", null, [plugin]);
|
||||
assert.deepEqual(section(admin, "Admin")!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
||||
assert.equal(section(admin, "Scheduling"), undefined);
|
||||
|
||||
// A plugin-permission holder sees the plugin section (reachable from "/") but not Admin.
|
||||
const member = buildDashboardModel(new URL("http://x/"), ["scheduling:read"], undefined, "", null, [plugin]);
|
||||
assert.ok(section(member, "Scheduling")!.children?.some((c) => c.href === "/scheduling/shifts"));
|
||||
assert.equal(section(member, "Admin"), undefined);
|
||||
|
||||
// Anonymous sees neither — composeNav drops a gated header and its whole subtree.
|
||||
const anon = buildDashboardModel(new URL("http://x/"), [], undefined, "", null, [plugin]);
|
||||
assert.equal(section(anon, "Admin"), undefined);
|
||||
assert.equal(section(anon, "Scheduling"), undefined);
|
||||
});
|
||||
|
||||
test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => {
|
||||
const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2"));
|
||||
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13
|
||||
assert.equal(rowCount(p2), 12);
|
||||
|
||||
// prev/next present on the middle page; both preserve the active sort.
|
||||
assert.match(p2.pagination.prev.href ?? "", /sort=-name/);
|
||||
assert.match(p2.pagination.next.href ?? "", /sort=-name/);
|
||||
|
||||
// Team filter actually narrows the set and adds a pill.
|
||||
const eng = buildDashboardModel(new URL("http://x/?team=Engineering"));
|
||||
assert.ok(eng.pagination.summary.total < 30);
|
||||
assert.deepEqual(eng.filterBar.pills.map((p) => p.label), ["Team"]);
|
||||
const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
|
||||
assert.equal(branded.shell.brand.name, "Acme Ops");
|
||||
assert.equal(branded.shell.theme, "dark");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user