Let a plugin add links to the profile menu, and list the setting conflict kind
CI / full-gate (push) Failing after 1m17s
CI / full-gate (push) Failing after 1m17s
This commit is contained in:
@@ -87,3 +87,29 @@ test("shellUser derives the profile from the real user; anonymous → Guest", ()
|
||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||
});
|
||||
|
||||
const account: Plugin = {
|
||||
apiVersion: "1.0.0", id: "account",
|
||||
profileMenu: [
|
||||
{ href: "/account/settings", icon: "i-user", label: "account.settings", session: true },
|
||||
{ href: "/account/billing", label: "account.billing", permission: "billing:read" },
|
||||
{ href: "/account/help", label: "Help", public: true },
|
||||
],
|
||||
};
|
||||
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; label: string }[] =>
|
||||
buildPluginChrome({
|
||||
localeHref: (href) => `${href}?locale=sv-SE`,
|
||||
menu: DEFAULT_MENU,
|
||||
plugins: [account],
|
||||
translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key),
|
||||
...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }),
|
||||
}).user.menu!.map(({ href, label }) => ({ href, label }));
|
||||
|
||||
test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => {
|
||||
assert.deepEqual(entries({}), [
|
||||
{ href: "/account/settings?locale=sv-SE", label: "Inställningar" },
|
||||
{ href: "/account/help?locale=sv-SE", label: "Help" },
|
||||
]);
|
||||
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);
|
||||
assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu
|
||||
});
|
||||
|
||||
+29
-1
@@ -2,6 +2,7 @@
|
||||
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
||||
// composeNav (override + per-user filter) and current-marked for the request path.
|
||||
|
||||
import { allows, type Gate } from "../auth/gate.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
@@ -11,9 +12,21 @@ import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||
|
||||
// A link in the shell's profile dropdown, above Sign out.
|
||||
export interface ProfileLink {
|
||||
href: string;
|
||||
icon?: string; // a Lucide sprite id (src/ui/icons.ts)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it.
|
||||
export interface ProfileMenuEntry extends ProfileLink, Gate {}
|
||||
|
||||
// `menu` rides on the user, which every view already hands to the shell, so no view needs a new local.
|
||||
export interface ShellUser {
|
||||
email: string;
|
||||
initials: string;
|
||||
menu?: ProfileLink[]; // permission-filtered, translated and locale-carried; the host always sets it, a hand-built fixture may omit it
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -66,10 +79,25 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
nav: carryLocaleInto(nav, carryLocale),
|
||||
signInHref: carryLocale(returnTo),
|
||||
...(theme != null ? { theme } : {}),
|
||||
user: shellUser(opts.user, t),
|
||||
user: { ...shellUser(opts.user, t), menu: profileLinks(opts, t, carryLocale) },
|
||||
};
|
||||
}
|
||||
|
||||
// A plugin's labels are keys in *its* catalog, hence its own translator. Only a signed-in visitor
|
||||
// has a profile menu; the gate then decides per entry.
|
||||
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
||||
const user = opts.user ?? null;
|
||||
if (!user) return [];
|
||||
return (opts.plugins ?? []).flatMap((plugin) => {
|
||||
const pluginT = opts.translatorFor?.(plugin.id) ?? t;
|
||||
return (plugin.profileMenu ?? []).filter((entry) => allows(entry, user)).map((entry) => ({
|
||||
href: carryLocale(entry.href),
|
||||
...(entry.icon != null ? { icon: entry.icon } : {}),
|
||||
label: pluginT(entry.label),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// The JWT holds only id/email/permissions, so the profile name is the email's local part.
|
||||
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||
if (!user) {
|
||||
|
||||
@@ -131,3 +131,17 @@ test("the chrome carries the visitor's language: breadcrumb links go through loc
|
||||
assert.match(html, /<a href="\/admin\/users\?locale=sv-SE">Users<\/a>/);
|
||||
assert.match(html, /<span>Ada<\/span>/); // the current crumb has no href to carry
|
||||
});
|
||||
|
||||
test("profile menu entries render as links above Sign out, with their icon", async () => {
|
||||
const html = await render({
|
||||
csrfToken: "tok.sig",
|
||||
user: {
|
||||
email: "ada@acme.io", initials: "AD", name: "ada",
|
||||
menu: [{ href: "/account/settings", icon: "i-user", label: "My <settings>" }, { href: "/account/help", label: "Help" }],
|
||||
},
|
||||
});
|
||||
const panel = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("</aside>"));
|
||||
assert.match(panel, /<a class="menu-item" href="\/account\/settings"><svg class="ico"><use href="#i-user" \/><\/svg>My <settings><\/a>/);
|
||||
assert.match(panel, /<a class="menu-item" href="\/account\/help">Help<\/a>/);
|
||||
assert.ok(panel.indexOf("/account/help") < panel.indexOf('class="menu-item-form"'), "Sign out stays last");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user