Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sidebar-active-trailing-slash.md
Original file line number Diff line number Diff line change
@@ -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/<id>/`) 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.
26 changes: 22 additions & 4 deletions packages/admin/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/`, 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}/`);
}

/**
Expand Down
17 changes: 17 additions & 0 deletions packages/admin/tests/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading