diff --git a/.changeset/sidebar-active-trailing-slash.md b/.changeset/sidebar-active-trailing-slash.md new file mode 100644 index 0000000000..aefcd08e3e --- /dev/null +++ b/.changeset/sidebar-active-trailing-slash.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes the sidebar active state for a plugin admin page declared with `path: "/"`. The nav target for such a page carries a trailing slash (`/plugins//`) while the router navigates without it, so the exact comparison in `isItemActive` never matched and the item stayed unhighlighted. This affected the shipped forms plugin's "Forms" page. Both sides are now normalized before comparing, and the admin root keeps its exact match. diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index 56f9d8a929..82edef7a2e 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -222,13 +222,31 @@ export function resolveItemPath(item: NavItem): string { return path; } -/** Checks if a nav item is active based on the current router path. */ +const TRAILING_SLASHES = /\/+$/; + +/** + * Drop trailing slashes so a target and the router's path compare equal. + * "/" keeps its meaning: it is the admin root, not an empty path. + */ +function stripTrailingSlash(value: string): string { + return value.length > 1 ? value.replace(TRAILING_SLASHES, "") : value; +} + +/** + * Checks if a nav item is active based on the current router path. + * + * Both sides are normalized because a plugin page declared with `path: "/"` + * makes the target `/plugins//`, while the router navigates to the same + * URL without the trailing slash — an exact compare would never match. + */ export function isItemActive(itemPath: string, currentPath: string): boolean { const queryIndex = itemPath.indexOf("?"); - const path = queryIndex === -1 ? itemPath : itemPath.slice(0, queryIndex); + const raw = queryIndex === -1 ? itemPath : itemPath.slice(0, queryIndex); + const path = stripTrailingSlash(raw); + const current = stripTrailingSlash(currentPath); return path === "/" - ? currentPath === "/" - : currentPath === path || currentPath.startsWith(`${path}/`); + ? current === "/" + : current === path || current.startsWith(`${path}/`); } /** diff --git a/packages/admin/tests/components/Sidebar.test.tsx b/packages/admin/tests/components/Sidebar.test.tsx index 3280985684..1d210c4b2f 100644 --- a/packages/admin/tests/components/Sidebar.test.tsx +++ b/packages/admin/tests/components/Sidebar.test.tsx @@ -100,6 +100,23 @@ describe("isItemActive", () => { it("matches taxonomy links independently of their locale query", () => { expect(isItemActive("/taxonomies/course?locale=de", "/taxonomies/course")).toBe(true); }); + + it('matches a plugin page declared with path "/", whose target carries a trailing slash', () => { + expect(isItemActive("/plugins/emdash-forms/", "/plugins/emdash-forms")).toBe(true); + }); + + it("still matches nested routes under a plugin page", () => { + expect(isItemActive("/plugins/emdash-forms/", "/plugins/emdash-forms/submissions")).toBe(true); + }); + + it("does not match a different plugin whose id shares a prefix", () => { + expect(isItemActive("/plugins/emdash-forms/", "/plugins/emdash-forms-extra")).toBe(false); + }); + + it("keeps the admin root exact", () => { + expect(isItemActive("/", "/media")).toBe(false); + expect(isItemActive("/", "/")).toBe(true); + }); }); describe("NavIcon", () => {