Add composeNav helper (todo §1); merge plugin nav fragments + central override (rename/group/order/hide), role-filter to a nav-tree model
This commit is contained in:
@@ -361,6 +361,7 @@ src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookie
|
|||||||
src/context.ts RequestContext handed to handlers + buildContext()
|
src/context.ts RequestContext handed to handlers + buildContext()
|
||||||
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
|
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
|
||||||
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
||||||
|
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
||||||
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
|
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
|
||||||
views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
|
views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
|
|||||||
69
src/nav.test.ts
Normal file
69
src/nav.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
|
||||||
|
// Two plugin fragments; ids let the override target nodes, `permission` gates per role.
|
||||||
|
const fragments: NavNode[][] = [
|
||||||
|
[{
|
||||||
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
|
children: [
|
||||||
|
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||||
|
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
||||||
|
];
|
||||||
|
|
||||||
|
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
|
||||||
|
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||||
|
|
||||||
|
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||||
|
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||||
|
assert.deepEqual(tree, [
|
||||||
|
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
|
||||||
|
// A header the user can't reach takes its whole subtree, even visible children.
|
||||||
|
const gatedHeader: NavNode[][] = [[
|
||||||
|
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||||
|
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||||
|
]];
|
||||||
|
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||||
|
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
||||||
|
const emptyHeader: NavNode[][] = [[
|
||||||
|
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
|
||||||
|
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
|
||||||
|
]];
|
||||||
|
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||||
|
|
||||||
|
// No fragments / no roles → empty tree, never throws.
|
||||||
|
assert.deepEqual(composeNav(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
|
||||||
|
const base: NavNode[][] = [[
|
||||||
|
{ href: "/a", id: "a", label: "Alpha" },
|
||||||
|
{ href: "/b", id: "b", label: "Beta" },
|
||||||
|
{ href: "/c", id: "c", label: "Gamma" },
|
||||||
|
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
|
||||||
|
]];
|
||||||
|
|
||||||
|
const tree = composeNav(base, {
|
||||||
|
rename: { a: "First" }, // relabel by id
|
||||||
|
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
||||||
|
order: ["grp", "a"], // grp before the lone a
|
||||||
|
hide: ["c"], // remove c from inside the group
|
||||||
|
}, ["root"]);
|
||||||
|
|
||||||
|
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
|
||||||
|
assert.deepEqual(tree, [
|
||||||
|
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
||||||
|
{ href: "/a", label: "First" },
|
||||||
|
{ href: "/secret", label: "Secret" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
128
src/nav.ts
Normal file
128
src/nav.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// composeNav (todo §1): merge each plugin's nav fragment into one tree, apply the central
|
||||||
|
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||||
|
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it declares no
|
||||||
|
// `permission` or `roles` includes that permission token; a gated header hides its whole
|
||||||
|
// subtree, and a pure header left with no children is dropped. The §2 config/menu.ts supplies
|
||||||
|
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||||
|
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||||
|
|
||||||
|
export interface NavNode {
|
||||||
|
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||||
|
children?: NavNode[];
|
||||||
|
count?: number;
|
||||||
|
current?: boolean;
|
||||||
|
href?: string;
|
||||||
|
icon?: string;
|
||||||
|
label: string;
|
||||||
|
open?: boolean;
|
||||||
|
permission?: string; // required role token; consumed by the filter, never rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central override (config/menu.ts, §2). Targets nodes by `id`; applied rename → group →
|
||||||
|
// order → hide, then the per-user permission filter runs last.
|
||||||
|
export interface NavOverride {
|
||||||
|
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||||
|
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
||||||
|
order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after
|
||||||
|
rename?: Record<string, string>; // id → replacement label
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroupSpec {
|
||||||
|
id: string;
|
||||||
|
children: string[]; // ids of top-level nodes to pull under the group, in this order
|
||||||
|
icon?: string;
|
||||||
|
label: string;
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeNav(
|
||||||
|
fragments: NavNode[][] = [],
|
||||||
|
override: NavOverride = {},
|
||||||
|
roles: string[] = [],
|
||||||
|
): NavNode[] {
|
||||||
|
let nodes: NavNode[] = fragments.flat();
|
||||||
|
if (override.rename) nodes = renameTree(nodes, override.rename);
|
||||||
|
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||||
|
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||||
|
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||||
|
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
const renamed = n.id != null ? rename[n.id] : undefined;
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
label: renamed != null ? renamed : n.label,
|
||||||
|
...(n.children ? { children: renameTree(n.children, rename) } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level only: each group becomes a header node placed where its first member sat;
|
||||||
|
// members are pulled out of the top level into the group, in the group's declared order.
|
||||||
|
function applyGroups(nodes: NavNode[], groups: NavGroupSpec[]): NavNode[] {
|
||||||
|
const ofChild = new Map<string, NavGroupSpec>();
|
||||||
|
for (const g of groups) for (const id of g.children) ofChild.set(id, g);
|
||||||
|
const byId = new Map(nodes.filter((n) => n.id != null).map((n) => [n.id as string, n]));
|
||||||
|
|
||||||
|
const build = (g: NavGroupSpec): NavNode => ({
|
||||||
|
id: g.id,
|
||||||
|
label: g.label,
|
||||||
|
...(g.icon ? { icon: g.icon } : {}),
|
||||||
|
...(g.open ? { open: g.open } : {}),
|
||||||
|
children: g.children.map((id) => byId.get(id)).filter((n): n is NavNode => n != null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const out: NavNode[] = [];
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
const g = n.id != null ? ofChild.get(n.id) : undefined;
|
||||||
|
if (!g) { out.push(n); continue; }
|
||||||
|
if (!emitted.has(g.id)) { out.push(build(g)); emitted.add(g.id); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOrder(nodes: NavNode[], order: string[]): NavNode[] {
|
||||||
|
const rank = new Map(order.map((id, i) => [id, i]));
|
||||||
|
const rankOf = (n: NavNode): number => (n.id != null && rank.has(n.id) ? (rank.get(n.id) as number) : Infinity);
|
||||||
|
return nodes
|
||||||
|
.map((n, i) => ({ i, n }))
|
||||||
|
.sort((a, b) => rankOf(a.n) - rankOf(b.n) || a.i - b.i) // stable: equal ranks keep input order
|
||||||
|
.map((x) => x.n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
||||||
|
const out: NavNode[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.id != null && hide.has(n.id)) continue;
|
||||||
|
out.push(n.children ? { ...n, children: hideTree(n.children, hide) } : n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
||||||
|
const out: NavNode[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree
|
||||||
|
if (!n.children) { out.push(n); continue; }
|
||||||
|
const children = filterByRoles(n.children, roles);
|
||||||
|
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||||
|
out.push({ ...n, children });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||||
|
// what nav-tree.ejs reads.
|
||||||
|
function toRenderNode(n: NavNode): NavNode {
|
||||||
|
const out: NavNode = { label: n.label };
|
||||||
|
if (n.icon != null) out.icon = n.icon;
|
||||||
|
if (n.href != null) out.href = n.href;
|
||||||
|
if (n.count != null) out.count = n.count;
|
||||||
|
if (n.current != null) out.current = n.current;
|
||||||
|
if (n.open != null) out.open = n.open;
|
||||||
|
if (n.children && n.children.length) out.children = n.children.map(toRenderNode);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
2
todo.md
2
todo.md
@@ -34,7 +34,7 @@ everything via Docker.
|
|||||||
- [x] Pagination partial — rows-per-page + page numbers, query-param driven. → `views/partials/pagination.ejs`: data-driven, zero-JS. `summary {from,to,total}`, rows-per-page GET `<form>` (select + submit, `hidden[]` carries list state), `pages: {label,href?,current?,ellipsis?}[]` (links; current/ellipsis inert), `prev`/`next` (href ⇒ link, omit ⇒ disabled). Reuses the mockup's `.pager` CSS, no changes. `pagination.test.ts` covers the matrix + value reflection + empty defaults.
|
- [x] Pagination partial — rows-per-page + page numbers, query-param driven. → `views/partials/pagination.ejs`: data-driven, zero-JS. `summary {from,to,total}`, rows-per-page GET `<form>` (select + submit, `hidden[]` carries list state), `pages: {label,href?,current?,ellipsis?}[]` (links; current/ellipsis inert), `prev`/`next` (href ⇒ link, omit ⇒ disabled). Reuses the mockup's `.pager` CSS, no changes. `pagination.test.ts` covers the matrix + value reflection + empty defaults.
|
||||||
- [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `<form class="auth-card">` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults.
|
- [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `<form class="auth-card">` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults.
|
||||||
- [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `<details>` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults.
|
- [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `<details>` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults.
|
||||||
- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree.
|
- [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix.
|
||||||
- [ ] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`.
|
- [ ] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`.
|
||||||
- [ ] Helper `paginate(total, page, pageSize)` → page model.
|
- [ ] Helper `paginate(total, page, pageSize)` → page model.
|
||||||
- [ ] Replace placeholder `index` with the app-shell dashboard.
|
- [ ] Replace placeholder `index` with the app-shell dashboard.
|
||||||
|
|||||||
Reference in New Issue
Block a user