diff --git a/examples/plugins/auth-admin/admin-shared.test.ts b/examples/plugins/auth-admin/admin-shared.test.ts index fd5f13b..8baefa5 100644 --- a/examples/plugins/auth-admin/admin-shared.test.ts +++ b/examples/plugins/auth-admin/admin-shared.test.ts @@ -6,8 +6,8 @@ import assert from "node:assert/strict"; import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; -import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api"; -import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts"; +import { GuardError, isValidPermissionName, type KetoClient, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api"; +import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, pagedTuples, permissionName, requirePermission } from "./admin-shared.ts"; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] }; @@ -100,3 +100,23 @@ test("buildConfirmModel wires the danger action, message, breadcrumbs and title assert.equal(model.title, "Delete user"); assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" }); }); + +test("pagedTuples follows the page token to the last page and keeps the query on every page", async () => { + const queries: unknown[] = []; + const tuple = (object: string) => ({ namespace: "Group", object, relation: "members", subject_id: "user:a" }); + const keto = { + listRelations: async (q: { pageToken?: string } = {}) => { + queries.push(q); + if (q.pageToken === "p2") return { nextPageToken: "p3", tuples: [tuple("b")] }; + if (q.pageToken === "p3") return { nextPageToken: null, tuples: [tuple("c")] }; + return { nextPageToken: "p2", tuples: [tuple("a")] }; + }, + } as unknown as KetoClient; + const all = await pagedTuples(keto, { namespace: "Group", relation: "members" }); + assert.deepEqual(all.map((t) => t.object), ["a", "b", "c"]); + assert.deepEqual(queries, [ + { namespace: "Group", relation: "members" }, + { namespace: "Group", pageToken: "p2", relation: "members" }, + { namespace: "Group", pageToken: "p3", relation: "members" }, + ]); +}); diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index c9f1603..f81c356 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -67,6 +67,16 @@ test("a gated section (like the admin plugin) shows to a holder; a sub-path mark assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined); }); +test("a path no nav leaf owns marks nothing current and opens nothing", () => { + const chrome = buildPageChrome({ + currentPath: "/elsewhere", menu: DEFAULT_MENU, plugins: [scheduling], + user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] }, + }); + const section = chrome.nav.find((n) => n.label === "Scheduling")!; + assert.equal(section.open, undefined); + assert.equal(section.children!.some((c) => c.current), false); +}); + test("branding logo + default theme flow through when set", () => { const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} }; const chrome = buildPageChrome({ menu }); diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index 5786721..62ea9dc 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -52,7 +52,6 @@ export interface ChromeOptions { user?: User | null; } -// The chrome of every page the host renders, its own screens and a plugin's alike. export function buildPageChrome(opts: ChromeOptions): PageChrome { const t = opts.t ?? ENGLISH; const carryLocale = opts.localeHref ?? ((href: string) => href); @@ -62,6 +61,7 @@ export function buildPageChrome(opts: ChromeOptions): PageChrome { return { brand, csrfToken: opts.csrfToken ?? "", + // Marked before the locale rides along, so an href still matches the plain request path. nav: carryLocaleInto(currentMarkedNav(opts, t), carryLocale), signInHref: carryLocale(returnTo), ...(theme != null ? { theme } : {}), @@ -69,8 +69,6 @@ export function buildPageChrome(opts: ChromeOptions): PageChrome { }; } -// Dashboard plus every plugin's fragment, composed and filtered for the user, with the current leaf -// marked. Marked before the locale rides along, so an href still matches the plain request path. function currentMarkedNav(opts: ChromeOptions, t: Translate): NavNode[] { const fragments: NavNode[][] = [[DASHBOARD_NAV]]; // A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's @@ -80,9 +78,7 @@ function currentMarkedNav(opts: ChromeOptions, t: Translate): NavNode[] { if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t)); } const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t); - // The *best* (longest) href that is the path or a parent of it, so a sub-path like - // /auth-admin/users/new marks the Users base leaf (/auth-admin/users) and the dashboard marks Dashboard. - const target = opts.currentPath ? bestHref(nav, opts.currentPath) : null; + const target = opts.currentPath ? bestHref(nav, opts.currentPath) : undefined; if (target) markCurrent(nav, target); return nav; }