fix(web): only ONE nav item highlighted at a time + drop redundant Close
Two related bugs from the same review pass:
1. /settings/users lit up BOTH the Admin and Settings entries in the
sidebar/drawer. The active-state check was naïve
`pathname.startsWith(href)`, which matches every parent prefix.
Replaced with a longest-match helper pickActiveNavKey() in
nav-config.ts: the most-specific item wins, parents stay quiet,
'/' only matches an exact pathname, and a strict-descendant check
(`href + '/'`) prevents `/settingsfoo` from lighting up Settings.
2. <DialogFooter showCloseButton> on the user-row delete (and three
other dialogs that I missed earlier) was rendering an extra outline
"Close" button next to the operator's own Cancel + Radix's corner X.
Stripped the prop from every remaining caller (login, dashboard
clear-history, reminder actions-bar, settings/users delete) so each
dialog footer shows just Cancel + the primary action.
Tests:
- nav-config.test.ts: 7 new cases covering the longest-match contract
— /settings/users highlights ONLY Admin, /settings highlights ONLY
Settings, '/' is exact-match only, sibling-prefix /settingsfoo
matches nothing, and a defense-in-depth probe asserts at-most-one
nav highlight across a representative pathname set.
- test/no-dialog-footer-show-close-button.test.ts: static guard that
grep-walks every production .tsx and fails if anything passes
`showCloseButton` to <DialogFooter>. Mirrors the existing
no-button-wrapping-card guard so the prop can't sneak back in.
Also self-checks the regex (matches single-line + multi-line +
other-prop combos; ignores clean DialogFooter and same-named props
on unrelated components).
463 → 477 web tests, all green; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
496f882d9c
commit
429ae0827f
@ -87,7 +87,7 @@ export function LoginFormClient({ next }: { next: string }) {
|
|||||||
Contact your administrator to reset it.
|
Contact your administrator to reset it.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" size="sm">
|
<Button type="button" size="sm">
|
||||||
Got it
|
Got it
|
||||||
|
|||||||
@ -217,7 +217,7 @@ export default async function DashboardPage() {
|
|||||||
themselves are not affected.
|
themselves are not affected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter>
|
||||||
<form action={clearHistoryAction}>
|
<form action={clearHistoryAction}>
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
|
|||||||
@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter>
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|||||||
@ -136,7 +136,7 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
|
|||||||
again. This cannot be undone.
|
again. This cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter showCloseButton>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button" variant="ghost" size="sm">
|
<Button type="button" variant="ghost" size="sm">
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
NAV_ITEMS,
|
NAV_ITEMS,
|
||||||
navItemsForRole,
|
navItemsForRole,
|
||||||
|
pickActiveNavKey,
|
||||||
type NavItem,
|
type NavItem,
|
||||||
type NavRole,
|
type NavRole,
|
||||||
} from "@/components/nav-config";
|
} from "@/components/nav-config";
|
||||||
@ -79,6 +80,7 @@ function MobileHeader({
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const activeKey = pickActiveNavKey(items, pathname);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Close the drawer when the route changes (i.e. the user picked a nav
|
// Close the drawer when the route changes (i.e. the user picked a nav
|
||||||
@ -144,7 +146,7 @@ function MobileHeader({
|
|||||||
className="flex flex-col gap-0.5 p-2"
|
className="flex flex-col gap-0.5 p-2"
|
||||||
>
|
>
|
||||||
{items.map(({ key, href, label, icon: Icon }) => {
|
{items.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = activeKey === key;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
@ -189,6 +191,7 @@ function Sidebar({
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const activeKey = pickActiveNavKey(items, pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { NAV_ITEMS, navItemsForRole } from "./nav-config";
|
import { NAV_ITEMS, navItemsForRole, pickActiveNavKey } from "./nav-config";
|
||||||
|
|
||||||
describe("navItemsForRole", () => {
|
describe("navItemsForRole", () => {
|
||||||
it("includes every NAV_ITEM for an admin", () => {
|
it("includes every NAV_ITEM for an admin", () => {
|
||||||
@ -31,3 +31,89 @@ describe("navItemsForRole", () => {
|
|||||||
expect(admin!.visibleTo).toEqual(["admin"]);
|
expect(admin!.visibleTo).toEqual(["admin"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pickActiveNavKey (longest-match active highlight)", () => {
|
||||||
|
// Use the real NAV_ITEMS so a future href change doesn't silently
|
||||||
|
// re-introduce the regression.
|
||||||
|
const adminItems = navItemsForRole("admin");
|
||||||
|
const userItems = navItemsForRole("user");
|
||||||
|
|
||||||
|
it("highlights ONLY the Admin entry on /settings/users (not Settings too)", () => {
|
||||||
|
// Repro of the user-reported regression. Naïve startsWith would
|
||||||
|
// light up both Settings (/settings) and Admin (/settings/users)
|
||||||
|
// because both prefixes match. The longest-match rule must pick
|
||||||
|
// the Admin entry alone.
|
||||||
|
const active = pickActiveNavKey(adminItems, "/settings/users");
|
||||||
|
expect(active).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights Settings on /settings exact, with Admin NOT lit", () => {
|
||||||
|
const active = pickActiveNavKey(adminItems, "/settings");
|
||||||
|
expect(active).toBe("settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights Settings on a subpath that is NOT /settings/users", () => {
|
||||||
|
// Admin nav is admin-only; this test is just to confirm the
|
||||||
|
// longest-match still picks Settings when no admin descendant
|
||||||
|
// claims the path.
|
||||||
|
const active = pickActiveNavKey(adminItems, "/settings/profile");
|
||||||
|
expect(active).toBe("settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights Dashboard ONLY on '/' exact (not on every route)", () => {
|
||||||
|
expect(pickActiveNavKey(adminItems, "/")).toBe("dashboard");
|
||||||
|
expect(pickActiveNavKey(adminItems, "/accounts")).toBe("accounts");
|
||||||
|
expect(pickActiveNavKey(adminItems, "/reminders/abc-123")).toBe("reminders");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing matches (e.g. a route hidden from this role)", () => {
|
||||||
|
// /settings/users isn't visible to a 'user' role, so the helper
|
||||||
|
// must NOT highlight it as Settings just because /settings is a
|
||||||
|
// prefix — we'd be claiming an item is active when the user can't
|
||||||
|
// navigate to it from this nav.
|
||||||
|
expect(pickActiveNavKey(userItems, "/settings/users")).toBe("settings");
|
||||||
|
// Neither item's href matches a totally foreign route.
|
||||||
|
expect(pickActiveNavKey(adminItems, "/elsewhere")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT match a sibling that shares a prefix string", () => {
|
||||||
|
// /settingsfoo is NOT a child of /settings — startsWith would
|
||||||
|
// mistakenly mark Settings active. The strict descendant check
|
||||||
|
// (`href + '/'`) prevents that.
|
||||||
|
expect(pickActiveNavKey(adminItems, "/settingsfoo")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each pathname highlights AT MOST one nav key (defense check)", () => {
|
||||||
|
// Walk a small representative set of routes and confirm we never
|
||||||
|
// light up two items at once. This is the contract the JSX in
|
||||||
|
// app-shell.tsx relies on.
|
||||||
|
const probes = [
|
||||||
|
"/",
|
||||||
|
"/accounts",
|
||||||
|
"/accounts/abc",
|
||||||
|
"/reminders",
|
||||||
|
"/reminders/abc",
|
||||||
|
"/activity",
|
||||||
|
"/activity?filter=success",
|
||||||
|
"/settings",
|
||||||
|
"/settings/users",
|
||||||
|
"/settings/users/something",
|
||||||
|
"/login",
|
||||||
|
"/elsewhere",
|
||||||
|
];
|
||||||
|
for (const path of probes) {
|
||||||
|
const matchCount = adminItems.filter((item) => {
|
||||||
|
if (item.href === "/") return path === "/";
|
||||||
|
return path === item.href || path.startsWith(item.href + "/");
|
||||||
|
}).length;
|
||||||
|
// If two prefixes both match, pickActiveNavKey must collapse
|
||||||
|
// them to one — that's the whole point of the helper.
|
||||||
|
const active = pickActiveNavKey(adminItems, path);
|
||||||
|
if (matchCount === 0) {
|
||||||
|
expect(active).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(active).not.toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -39,3 +39,39 @@ export function navItemsForRole(role: NavRole): NavItem[] {
|
|||||||
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
|
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the SINGLE active nav item for a given pathname. Solves the
|
||||||
|
* "Admin and Settings both highlighted on /settings/users" bug:
|
||||||
|
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
|
||||||
|
* Admin entry) AND /settings (its parent). Two items lit up at once
|
||||||
|
* looks broken.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - The Dashboard ('/') item only matches an exact pathname match;
|
||||||
|
* otherwise it would shadow every other route.
|
||||||
|
* - All other items match either an exact pathname or a strict
|
||||||
|
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
|
||||||
|
* its own would also match `/settingsfoo`, which is wrong.
|
||||||
|
* - When two non-root items both match (parent + child), pick the
|
||||||
|
* LONGEST href so the more specific entry wins.
|
||||||
|
*
|
||||||
|
* Returns the active item's `key`, or null if no item matches (e.g.
|
||||||
|
* the user navigated to a route that isn't in the visible nav).
|
||||||
|
*/
|
||||||
|
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
|
||||||
|
let best: NavItem | null = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.href === "/") {
|
||||||
|
if (pathname === "/") best = item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isMatch =
|
||||||
|
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
|
if (!isMatch) continue;
|
||||||
|
if (!best || item.href.length > best.href.length) {
|
||||||
|
best = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.key ?? null;
|
||||||
|
}
|
||||||
|
|||||||
112
apps/web/src/test/no-dialog-footer-show-close-button.test.ts
Normal file
112
apps/web/src/test/no-dialog-footer-show-close-button.test.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static guard: no production `.tsx` file may pass `showCloseButton`
|
||||||
|
* to `<DialogFooter>`.
|
||||||
|
*
|
||||||
|
* Why: the shared DialogFooter renders an EXTRA outline-styled
|
||||||
|
* <Button>Close</Button> when `showCloseButton` is set. Every dialog
|
||||||
|
* we have that already provides its own primary action also includes
|
||||||
|
* a Cancel/dismiss button (either via DialogClose or by closing the
|
||||||
|
* Dialog state on submit) — and Radix's auto-rendered corner X
|
||||||
|
* already gives users a third way out. The redundant Close button
|
||||||
|
* cluttered the footer and shipped to production multiple times
|
||||||
|
* before this guard existed; this test stops it from regressing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SRC_ROOT = join(__dirname, "..");
|
||||||
|
|
||||||
|
function listTsxFiles(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
const st = statSync(full);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
out.push(...listTsxFiles(full));
|
||||||
|
} else if (entry.endsWith(".tsx")) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Hit {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
excerpt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHits(content: string): Array<{ line: number; excerpt: string }> {
|
||||||
|
const hits: Array<{ line: number; excerpt: string }> = [];
|
||||||
|
// Match `<DialogFooter` with `showCloseButton` somewhere in the
|
||||||
|
// opening tag. Stops at `>` so we don't accidentally cross into the
|
||||||
|
// children. Multi-line opening tags are handled by `[\s\S]`.
|
||||||
|
const matches = content.matchAll(
|
||||||
|
/<DialogFooter\b[\s\S]*?showCloseButton\b[\s\S]*?>/g,
|
||||||
|
);
|
||||||
|
for (const m of matches) {
|
||||||
|
const idx = m.index ?? 0;
|
||||||
|
const line = content.slice(0, idx).split("\n").length;
|
||||||
|
hits.push({ line, excerpt: m[0].slice(0, 120).replace(/\s+/g, " ") });
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("static guard: no <DialogFooter showCloseButton>", () => {
|
||||||
|
// Skip this test file (it intentionally contains the pattern strings)
|
||||||
|
// and all other .test.tsx files (they're examples, not production UI).
|
||||||
|
const files = listTsxFiles(SRC_ROOT).filter(
|
||||||
|
(f) => !/\.test\.tsx?$/.test(f),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("scans at least one source file (sanity)", () => {
|
||||||
|
expect(files.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds no <DialogFooter showCloseButton ...> in any production .tsx file", () => {
|
||||||
|
const allHits: Hit[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, "utf8");
|
||||||
|
for (const h of findHits(content)) {
|
||||||
|
allHits.push({ file: relative(SRC_ROOT, file), ...h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allHits.length > 0) {
|
||||||
|
const message = allHits
|
||||||
|
.map((h) => ` ${h.file}:${h.line} → ${h.excerpt}`)
|
||||||
|
.join("\n");
|
||||||
|
throw new Error(
|
||||||
|
`Redundant Close button detected — <DialogFooter showCloseButton ...>:\n${message}\n` +
|
||||||
|
`The DialogFooter component injects an extra "Close" button when this prop\n` +
|
||||||
|
`is set. Every existing caller already has its own Cancel/Close action plus\n` +
|
||||||
|
`Radix's corner X — a third Close is just visual noise. Remove the prop.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(allHits).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findHits parser", () => {
|
||||||
|
it("matches a single-line <DialogFooter showCloseButton>", () => {
|
||||||
|
expect(
|
||||||
|
findHits("<DialogFooter showCloseButton>foo</DialogFooter>"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("matches when other props are present alongside showCloseButton", () => {
|
||||||
|
expect(
|
||||||
|
findHits('<DialogFooter className="x" showCloseButton>'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("matches across multiple lines", () => {
|
||||||
|
const src = `<DialogFooter\n className="x"\n showCloseButton\n>`;
|
||||||
|
expect(findHits(src)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("does NOT match a clean <DialogFooter>", () => {
|
||||||
|
expect(findHits("<DialogFooter>x</DialogFooter>")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it("does NOT match a similarly-named prop on an unrelated component", () => {
|
||||||
|
expect(findHits("<SheetFooter showCloseButton>")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user