feat(admin): configurable sidebar menu order and visibility
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
1127807aaa
commit
0224b34f15
11 files changed
+700
-5
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { LayoutDashboard, Newspaper, Settings } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_PINNED_HREFS,
|
||||
applyAdminNavConfig,
|
||||
parseAdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
|
||||
const catalog: AdminNavGroup[] = [
|
||||
{
|
||||
labelKey: "overview",
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{ href: "/admin", labelKey: "dashboard", icon: LayoutDashboard },
|
||||
{ href: "/admin/menu", labelKey: "menu", icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "content",
|
||||
icon: Newspaper,
|
||||
items: [
|
||||
{ href: "/admin/articles", labelKey: "articles", icon: Newspaper },
|
||||
{ href: "/admin/photos", labelKey: "photos", icon: Newspaper },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
items: [{ href: "/admin/settings", labelKey: "settings", icon: Settings }],
|
||||
},
|
||||
];
|
||||
|
||||
describe("admin-nav-config", () => {
|
||||
it("parses empty / invalid as empty config", () => {
|
||||
expect(parseAdminNavConfig(null)).toEqual({});
|
||||
expect(parseAdminNavConfig("")).toEqual({});
|
||||
expect(parseAdminNavConfig("not-json")).toEqual({});
|
||||
expect(parseAdminNavConfig("[]")).toEqual({});
|
||||
});
|
||||
|
||||
it("reorders groups and items", () => {
|
||||
const applied = applyAdminNavConfig(catalog, {
|
||||
groupOrder: ["system", "content", "overview"],
|
||||
itemOrder: {
|
||||
content: ["/admin/photos", "/admin/articles"],
|
||||
},
|
||||
});
|
||||
expect(applied.map((g) => g.labelKey)).toEqual([
|
||||
"system",
|
||||
"content",
|
||||
"overview",
|
||||
]);
|
||||
expect(applied[1]?.items.map((i) => i.href)).toEqual([
|
||||
"/admin/photos",
|
||||
"/admin/articles",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides groups and items but keeps pinned hrefs", () => {
|
||||
const applied = applyAdminNavConfig(catalog, {
|
||||
hiddenGroups: ["system"],
|
||||
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
|
||||
});
|
||||
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
|
||||
expect(applied[0]?.items.map((i) => i.href)).toEqual([
|
||||
"/admin",
|
||||
"/admin/menu",
|
||||
]);
|
||||
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
|
||||
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips serialize → parse", () => {
|
||||
const raw = serializeAdminNavConfig({
|
||||
groupOrder: ["content"],
|
||||
hiddenGroups: ["system"],
|
||||
hiddenItems: ["/admin/photos", "/admin"],
|
||||
itemOrder: { content: ["/admin/articles"] },
|
||||
});
|
||||
const parsed = parseAdminNavConfig(raw);
|
||||
expect(parsed.groupOrder).toEqual(["content"]);
|
||||
expect(parsed.hiddenGroups).toEqual(["system"]);
|
||||
expect(parsed.hiddenItems).toEqual(["/admin/photos"]);
|
||||
expect(parsed.itemOrder).toEqual({ content: ["/admin/articles"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||
|
||||
/** website_settings key storing JSON overlay for the admin sidebar. */
|
||||
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
|
||||
|
||||
/** Hrefs that cannot be hidden (dashboard + menu editor). */
|
||||
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin", "/admin/menu"]);
|
||||
|
||||
export type AdminNavConfig = {
|
||||
/** Group labelKeys in display order. Missing groups append in catalog order. */
|
||||
groupOrder?: string[];
|
||||
/** Group labelKeys fully hidden from the sidebar. */
|
||||
hiddenGroups?: string[];
|
||||
/** Item hrefs hidden from the sidebar (except pinned). */
|
||||
hiddenItems?: string[];
|
||||
/** Per-group item href order. Missing items append in catalog order. */
|
||||
itemOrder?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
||||
}
|
||||
|
||||
/** Parse stored JSON; invalid / empty → empty config. */
|
||||
export function parseAdminNavConfig(
|
||||
raw: string | null | undefined,
|
||||
): AdminNavConfig {
|
||||
if (!raw?.trim()) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const o = parsed as Record<string, unknown>;
|
||||
const config: AdminNavConfig = {};
|
||||
if (isStringArray(o.groupOrder)) config.groupOrder = o.groupOrder;
|
||||
if (isStringArray(o.hiddenGroups)) config.hiddenGroups = o.hiddenGroups;
|
||||
if (isStringArray(o.hiddenItems)) config.hiddenItems = o.hiddenItems;
|
||||
if (
|
||||
o.itemOrder &&
|
||||
typeof o.itemOrder === "object" &&
|
||||
!Array.isArray(o.itemOrder)
|
||||
) {
|
||||
const itemOrder: Record<string, string[]> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
o.itemOrder as Record<string, unknown>,
|
||||
)) {
|
||||
if (isStringArray(v)) itemOrder[k] = v;
|
||||
}
|
||||
config.itemOrder = itemOrder;
|
||||
}
|
||||
return config;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function orderByKeys<T>(
|
||||
items: T[],
|
||||
order: string[] | undefined,
|
||||
keyOf: (item: T) => string,
|
||||
): T[] {
|
||||
if (!order?.length) return items;
|
||||
const byKey = new Map(items.map((item) => [keyOf(item), item]));
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const key of order) {
|
||||
const hit = byKey.get(key);
|
||||
if (!hit || seen.has(key)) continue;
|
||||
out.push(hit);
|
||||
seen.add(key);
|
||||
}
|
||||
for (const item of items) {
|
||||
const key = keyOf(item);
|
||||
if (seen.has(key)) continue;
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply owner overlay (hide + reorder) on top of the code catalog.
|
||||
* Does not apply ACL — callers filter permissions afterwards.
|
||||
*/
|
||||
export function applyAdminNavConfig(
|
||||
groups: AdminNavGroup[],
|
||||
config: AdminNavConfig,
|
||||
): AdminNavGroup[] {
|
||||
const hiddenGroups = new Set(config.hiddenGroups ?? []);
|
||||
const hiddenItems = new Set(config.hiddenItems ?? []);
|
||||
|
||||
const mapped = groups
|
||||
.filter((g) => !hiddenGroups.has(g.labelKey))
|
||||
.map((group) => {
|
||||
const items = group.items.filter((item) => {
|
||||
if (ADMIN_NAV_PINNED_HREFS.has(item.href)) return true;
|
||||
return !hiddenItems.has(item.href);
|
||||
});
|
||||
const orderedItems = orderByKeys(
|
||||
items,
|
||||
config.itemOrder?.[group.labelKey],
|
||||
(item) => item.href,
|
||||
);
|
||||
return { ...group, items: orderedItems };
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return orderByKeys(mapped, config.groupOrder, (g) => g.labelKey);
|
||||
}
|
||||
|
||||
export function serializeAdminNavConfig(config: AdminNavConfig): string {
|
||||
return JSON.stringify({
|
||||
groupOrder: config.groupOrder ?? [],
|
||||
hiddenGroups: config.hiddenGroups ?? [],
|
||||
hiddenItems: (config.hiddenItems ?? []).filter(
|
||||
(href) => !ADMIN_NAV_PINNED_HREFS.has(href),
|
||||
),
|
||||
itemOrder: config.itemOrder ?? {},
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
KeyRound,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
Megaphone,
|
||||
Monitor,
|
||||
Newspaper,
|
||||
@@ -528,6 +529,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
icon: Cog,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/menu",
|
||||
labelKey: "adminMenu",
|
||||
icon: ListOrdered,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/theme",
|
||||
labelKey: "theme",
|
||||
|
||||
@@ -58,6 +58,20 @@ describe("staff smoke contract", () => {
|
||||
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("ships dynamic admin menu overlay", () => {
|
||||
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
|
||||
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"/admin/menu",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
|
||||
).toContain("applyAdminNavConfig");
|
||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
|
||||
"ADMIN_NAV_CONFIG_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps force-dynamic only on admin/mod layouts", () => {
|
||||
const hits: string[] = [];
|
||||
function walk(dir: string) {
|
||||
|
||||
Reference in new issue
Block a user