refactor(admin): remove legacy menu editor
This commit is contained in:
1 parent
98b0009206
commit
667637f8da
12 files changed
+13
-461
No files matched your search
@@ -1,43 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
type AdminNavConfig,
|
|
||||||
serializeAdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
groupOrder: z.array(z.string()),
|
|
||||||
hiddenGroups: z.array(z.string()),
|
|
||||||
hiddenItems: z.array(z.string()),
|
|
||||||
itemOrder: z.record(z.string(), z.array(z.string())),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const saveAdminNavConfig = adminAction(
|
|
||||||
{
|
|
||||||
permission: PERMS.SETTINGS_EDIT,
|
|
||||||
schema,
|
|
||||||
rateLimitKey: "admin-nav-config-save",
|
|
||||||
rateLimitMax: 30,
|
|
||||||
},
|
|
||||||
async (ctx) => {
|
|
||||||
const config: AdminNavConfig = {
|
|
||||||
groupOrder: ctx.data.groupOrder,
|
|
||||||
hiddenGroups: ctx.data.hiddenGroups,
|
|
||||||
hiddenItems: ctx.data.hiddenItems,
|
|
||||||
itemOrder: ctx.data.itemOrder,
|
|
||||||
};
|
|
||||||
await siteSettings.update(
|
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
serializeAdminNavConfig(config),
|
|
||||||
);
|
|
||||||
revalidatePath("/admin", "layout");
|
|
||||||
revalidatePath("/admin/menu");
|
|
||||||
return actionOk({ saved: true });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
RotateCcw,
|
|
||||||
Save,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { saveAdminNavConfig } from "@/actions/admin-nav-menu";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
ADMIN_NAV_PINNED_HREFS,
|
|
||||||
type AdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type EditableNavGroup = {
|
|
||||||
labelKey: string;
|
|
||||||
items: { href: string; labelKey: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function moveIndex<T>(arr: T[], from: number, to: number): T[] {
|
|
||||||
if (to < 0 || to >= arr.length || from === to) return arr;
|
|
||||||
const next = [...arr];
|
|
||||||
const [item] = next.splice(from, 1);
|
|
||||||
if (item === undefined) return arr;
|
|
||||||
next.splice(to, 0, item);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfig(
|
|
||||||
groups: EditableNavGroup[],
|
|
||||||
hiddenGroups: Set<string>,
|
|
||||||
hiddenItems: Set<string>,
|
|
||||||
): Required<AdminNavConfig> {
|
|
||||||
return {
|
|
||||||
groupOrder: groups.map((g) => g.labelKey),
|
|
||||||
hiddenGroups: [...hiddenGroups],
|
|
||||||
hiddenItems: [...hiddenItems].filter((h) => !ADMIN_NAV_PINNED_HREFS.has(h)),
|
|
||||||
itemOrder: Object.fromEntries(
|
|
||||||
groups.map((g) => [g.labelKey, g.items.map((i) => i.href)]),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminMenuEditor({
|
|
||||||
catalog,
|
|
||||||
initialConfig,
|
|
||||||
canEdit,
|
|
||||||
}: {
|
|
||||||
catalog: EditableNavGroup[];
|
|
||||||
initialConfig: AdminNavConfig;
|
|
||||||
canEdit: boolean;
|
|
||||||
}) {
|
|
||||||
const tNav = useTranslations("pages.admin.nav");
|
|
||||||
const t = useTranslations("pages.admin.menu");
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const initialGroups = useMemo(() => {
|
|
||||||
const order = initialConfig.groupOrder ?? [];
|
|
||||||
const byKey = new Map(catalog.map((g) => [g.labelKey, g]));
|
|
||||||
const ordered: EditableNavGroup[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const key of order) {
|
|
||||||
const g = byKey.get(key);
|
|
||||||
if (!g || seen.has(key)) continue;
|
|
||||||
const itemOrder = initialConfig.itemOrder?.[key] ?? [];
|
|
||||||
const itemsByHref = new Map(g.items.map((i) => [i.href, i]));
|
|
||||||
const items = [
|
|
||||||
...itemOrder
|
|
||||||
.map((href) => itemsByHref.get(href))
|
|
||||||
.filter((i): i is NonNullable<typeof i> => Boolean(i)),
|
|
||||||
...g.items.filter((i) => !itemOrder.includes(i.href)),
|
|
||||||
];
|
|
||||||
ordered.push({ labelKey: g.labelKey, items });
|
|
||||||
seen.add(key);
|
|
||||||
}
|
|
||||||
for (const g of catalog) {
|
|
||||||
if (seen.has(g.labelKey)) continue;
|
|
||||||
ordered.push({ ...g, items: [...g.items] });
|
|
||||||
}
|
|
||||||
return ordered;
|
|
||||||
}, [catalog, initialConfig]);
|
|
||||||
|
|
||||||
const [groups, setGroups] = useState(initialGroups);
|
|
||||||
const [hiddenGroups, setHiddenGroups] = useState(
|
|
||||||
() => new Set(initialConfig.hiddenGroups ?? []),
|
|
||||||
);
|
|
||||||
const [hiddenItems, setHiddenItems] = useState(
|
|
||||||
() => new Set(initialConfig.hiddenItems ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setGroups(catalog.map((g) => ({ ...g, items: [...g.items] })));
|
|
||||||
setHiddenGroups(new Set());
|
|
||||||
setHiddenItems(new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
if (!canEdit) return;
|
|
||||||
const config = buildConfig(groups, hiddenGroups, hiddenItems);
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await saveAdminNavConfig(config);
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success(t("saved"));
|
|
||||||
} else {
|
|
||||||
toast.error(res.error ?? t("saveFailed"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<>
|
|
||||||
<Button type="button" onClick={save} disabled={pending}>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{pending ? t("saving") : t("save")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={reset}
|
|
||||||
disabled={pending}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
{t("reset")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground w-full sm:w-auto sm:ml-auto">
|
|
||||||
{t("hint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{groups.map((group, gi) => {
|
|
||||||
const groupHidden = hiddenGroups.has(group.labelKey);
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={group.labelKey}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3",
|
|
||||||
groupHidden && "opacity-55",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-sm uppercase tracking-wide flex-1 min-w-0">
|
|
||||||
{tNav(group.labelKey)}
|
|
||||||
</span>
|
|
||||||
{canEdit && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={gi === 0}
|
|
||||||
aria-label={t("moveUp")}
|
|
||||||
onClick={() =>
|
|
||||||
setGroups((prev) => moveIndex(prev, gi, gi - 1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={gi === groups.length - 1}
|
|
||||||
aria-label={t("moveDown")}
|
|
||||||
onClick={() =>
|
|
||||||
setGroups((prev) => moveIndex(prev, gi, gi + 1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={groupHidden ? t("showGroup") : t("hideGroup")}
|
|
||||||
onClick={() =>
|
|
||||||
setHiddenGroups((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(group.labelKey))
|
|
||||||
next.delete(group.labelKey);
|
|
||||||
else next.add(group.labelKey);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{groupHidden ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{group.items.map((item, ii) => {
|
|
||||||
const pinned = ADMIN_NAV_PINNED_HREFS.has(item.href);
|
|
||||||
const itemHidden = !pinned && hiddenItems.has(item.href);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-wrap items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
|
||||||
"bg-[var(--admin-canvas)]/60",
|
|
||||||
itemHidden && "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex-1 min-w-0 truncate">
|
|
||||||
{tNav(item.labelKey)}
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground font-mono">
|
|
||||||
{item.href}
|
|
||||||
</span>
|
|
||||||
{pinned && (
|
|
||||||
<span className="ml-2 text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
|
||||||
{t("pinned")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{canEdit && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={ii === 0}
|
|
||||||
aria-label={t("moveUp")}
|
|
||||||
onClick={() =>
|
|
||||||
setGroups((prev) =>
|
|
||||||
prev.map((g, idx) =>
|
|
||||||
idx === gi
|
|
||||||
? {
|
|
||||||
...g,
|
|
||||||
items: moveIndex(g.items, ii, ii - 1),
|
|
||||||
}
|
|
||||||
: g,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={ii === group.items.length - 1}
|
|
||||||
aria-label={t("moveDown")}
|
|
||||||
onClick={() =>
|
|
||||||
setGroups((prev) =>
|
|
||||||
prev.map((g, idx) =>
|
|
||||||
idx === gi
|
|
||||||
? {
|
|
||||||
...g,
|
|
||||||
items: moveIndex(g.items, ii, ii + 1),
|
|
||||||
}
|
|
||||||
: g,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={pinned}
|
|
||||||
aria-label={
|
|
||||||
itemHidden ? t("showItem") : t("hideItem")
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (pinned) return;
|
|
||||||
setHiddenItems((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(item.href)) next.delete(item.href);
|
|
||||||
else next.add(item.href);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{itemHidden || pinned ? (
|
|
||||||
itemHidden ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4 opacity-40" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { ListOrdered } from "lucide-react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
|
||||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
|
||||||
import {
|
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
parseAdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
import { AdminMenuEditor } from "./admin-menu-editor";
|
|
||||||
|
|
||||||
export const metadata = { title: "Admin menu" };
|
|
||||||
|
|
||||||
export default async function AdminMenuPage() {
|
|
||||||
const { session, permissions } = await getAdminContext();
|
|
||||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
|
||||||
redirect("/admin");
|
|
||||||
}
|
|
||||||
const canEdit = canAccess(
|
|
||||||
permissions,
|
|
||||||
PERMS.SETTINGS_EDIT,
|
|
||||||
session.user.rank,
|
|
||||||
);
|
|
||||||
const t = await getTranslations("pages.admin.menu");
|
|
||||||
|
|
||||||
const catalog = ADMIN_NAV_GROUPS.map((g) => ({
|
|
||||||
labelKey: g.labelKey,
|
|
||||||
items: g.items.map((i) => ({ href: i.href, labelKey: i.labelKey })),
|
|
||||||
}));
|
|
||||||
const initialConfig = parseAdminNavConfig(
|
|
||||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminPageShell
|
|
||||||
icon={ListOrdered}
|
|
||||||
title={t("title")}
|
|
||||||
subtitle={t("subtitle")}
|
|
||||||
>
|
|
||||||
<AdminMenuEditor
|
|
||||||
catalog={catalog}
|
|
||||||
initialConfig={initialConfig}
|
|
||||||
canEdit={canEdit}
|
|
||||||
/>
|
|
||||||
</AdminPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -612,8 +612,8 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
||||||
expect(discovered).toHaveLength(139);
|
expect(discovered).toHaveLength(138);
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
|||||||
it("discovers the exact legacy administration inventory", () => {
|
it("discovers the exact legacy administration inventory", () => {
|
||||||
const pages = discoverLegacyPages();
|
const pages = discoverLegacyPages();
|
||||||
|
|
||||||
expect(pages).toHaveLength(139);
|
expect(pages).toHaveLength(138);
|
||||||
expect(pages).toContainEqual({
|
expect(pages).toContainEqual({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/users/:id/edit",
|
legacyPath: "/admin/users/:id/edit",
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
|||||||
import { validateMigrationEntries } from "./validate-matrix";
|
import { validateMigrationEntries } from "./validate-matrix";
|
||||||
|
|
||||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||||
it("covers all 139 legacy pages exactly once", () => {
|
it("covers all 138 legacy pages exactly once", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ const SYSTEM_PREFIXES = [
|
|||||||
"/admin/housekeeping",
|
"/admin/housekeeping",
|
||||||
"/admin/logs",
|
"/admin/logs",
|
||||||
"/admin/maintenance",
|
"/admin/maintenance",
|
||||||
"/admin/menu",
|
|
||||||
"/admin/permissions",
|
"/admin/permissions",
|
||||||
"/admin/settings",
|
"/admin/settings",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("systemMigrationEntries", () => {
|
describe("systemMigrationEntries", () => {
|
||||||
it("covers all 20 System pages exactly once", () => {
|
it("covers all 19 System pages exactly once", () => {
|
||||||
expect(systemMigrationEntries).toHaveLength(20);
|
expect(systemMigrationEntries).toHaveLength(19);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(
|
validateMigrationEntries(
|
||||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||||
@@ -37,11 +36,6 @@ describe("systemMigrationEntries", () => {
|
|||||||
decision: "REMOVE",
|
decision: "REMOVE",
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
legacyPath: "/admin/menu",
|
|
||||||
decision: "REMOVE",
|
|
||||||
targetPath: null,
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -346,28 +346,6 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [
|
|||||||
accessibility: "PARTIAL",
|
accessibility: "PARTIAL",
|
||||||
notes: ["Maintenance changes global login availability and splash content"],
|
notes: ["Maintenance changes global login availability and splash content"],
|
||||||
}),
|
}),
|
||||||
plannedSystemEntry({
|
|
||||||
surface: "admin",
|
|
||||||
legacyPath: "/admin/menu",
|
|
||||||
sourceFile: "src/app/admin/menu/page.tsx",
|
|
||||||
targetPath: null,
|
|
||||||
decision: "REMOVE",
|
|
||||||
capabilities: {
|
|
||||||
read: [PERMS.SETTINGS_VIEW],
|
|
||||||
mutate: [PERMS.SETTINGS_EDIT],
|
|
||||||
},
|
|
||||||
dependencies: {
|
|
||||||
queries: ["ADMIN_NAV_GROUPS", "siteSettings.get"],
|
|
||||||
mutations: ["saveAdminNavConfig", "siteSettings.update"],
|
|
||||||
},
|
|
||||||
auditRequirement: "PRIVILEGED_MUTATION",
|
|
||||||
localization: "PARTIAL",
|
|
||||||
accessibility: "PARTIAL",
|
|
||||||
requiredTests: ["integration", "e2e"],
|
|
||||||
notes: [
|
|
||||||
"Remove the manual legacy menu editor because foundation manifests own navigation",
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
plannedSystemEntry({
|
plannedSystemEntry({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/permissions",
|
legacyPath: "/admin/permissions",
|
||||||
|
|||||||
@@ -64,10 +64,7 @@ describe("admin-nav-config", () => {
|
|||||||
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
|
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
|
||||||
});
|
});
|
||||||
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
|
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
|
||||||
expect(applied[0]?.items.map((i) => i.href)).toEqual([
|
expect(applied[0]?.items.map((i) => i.href)).toEqual(["/admin"]);
|
||||||
"/admin",
|
|
||||||
"/admin/menu",
|
|
||||||
]);
|
|
||||||
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
|
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
|
||||||
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
|
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { AdminNavGroup } from "@/lib/admin-nav";
|
|||||||
/** website_settings key storing JSON overlay for the admin sidebar. */
|
/** website_settings key storing JSON overlay for the admin sidebar. */
|
||||||
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
|
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
|
||||||
|
|
||||||
/** Hrefs that cannot be hidden (dashboard + menu editor). */
|
/** Hrefs that cannot be hidden (dashboard). */
|
||||||
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin", "/admin/menu"]);
|
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin"]);
|
||||||
|
|
||||||
export type AdminNavConfig = {
|
export type AdminNavConfig = {
|
||||||
/** Group labelKeys in display order. Missing groups append in catalog order. */
|
/** Group labelKeys in display order. Missing groups append in catalog order. */
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Languages,
|
Languages,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListOrdered,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -541,12 +540,6 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
icon: Cog,
|
icon: Cog,
|
||||||
permission: PERMS.SETTINGS_VIEW,
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/admin/menu",
|
|
||||||
labelKey: "adminMenu",
|
|
||||||
icon: ListOrdered,
|
|
||||||
permission: PERMS.SETTINGS_VIEW,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/admin/theme",
|
href: "/admin/theme",
|
||||||
labelKey: "theme",
|
labelKey: "theme",
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ describe("staff smoke contract", () => {
|
|||||||
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ships dynamic admin menu overlay", () => {
|
it("keeps sidebar configuration without exposing the retired menu editor", () => {
|
||||||
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
|
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
|
||||||
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(true);
|
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(false);
|
||||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
|
||||||
"/admin/menu",
|
"/admin/menu",
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Reference in new issue
Block a user