Introduces the 56px left icon rail specified in docs/specs/2026-04-11-nexus-layout-overhaul.md §4.1. Four primary destinations (Assistant, Studio, Projects, Settings) rendered as Lucide icons with silver default + volt active state and a 2px volt bar on the right edge of the active item. Destinations are company-prefixed except Settings, which points at the global /instance/settings/general route. The Studio icon also highlights on /convert because Phase 10 folds ConvertPage into Studio as a workshop. The Projects icon is the umbrella for all Phase 11 per-project-tab routes (issues, agents, routines, goals, approvals, costs, activity, inbox, execution-workspaces). The rail is not yet mounted in Layout.tsx — that happens in task 6. Part of the Nexus v1.7 structural overhaul (Phase 8 of MIGRATION-PLAN.md §8b). Companion tests cover all 4 destinations, active-state derivation, and aria-current semantics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { MemoryRouter } from "@/lib/router";
|
|
import { IconRail } from "./IconRail";
|
|
|
|
// The real @/lib/router Link wrapper calls useCompany(), which throws
|
|
// outside a CompanyProvider. The IconRail doesn't depend on the company
|
|
// context for building URLs (it receives companyPrefix as a prop), so we
|
|
// stub useCompany() to return a minimal value so Link can render in tests.
|
|
vi.mock("@/context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
companies: [],
|
|
selectedCompanyId: null,
|
|
selectedCompany: null,
|
|
selectionSource: "bootstrap" as const,
|
|
loading: false,
|
|
error: null,
|
|
setSelectedCompanyId: () => {},
|
|
reloadCompanies: async () => {},
|
|
createCompany: async () => {
|
|
throw new Error("not implemented in test stub");
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// Tell React this environment uses act() for event flushing.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
describe("IconRail", () => {
|
|
let container: HTMLDivElement;
|
|
let root: ReturnType<typeof createRoot> | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root!.unmount();
|
|
});
|
|
root = null;
|
|
}
|
|
if (container.parentNode) {
|
|
container.remove();
|
|
}
|
|
});
|
|
|
|
function renderRail(initialPath: string) {
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root!.render(
|
|
<MemoryRouter initialEntries={[initialPath]}>
|
|
<IconRail companyPrefix="NEX" />
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
return {
|
|
getNav: () => container.querySelector("nav[aria-label='Primary']") as HTMLElement,
|
|
getLinks: () =>
|
|
Array.from(container.querySelectorAll("nav[aria-label='Primary'] a")) as HTMLAnchorElement[],
|
|
getLinkByLabel: (label: string) =>
|
|
container.querySelector(`nav[aria-label='Primary'] a[aria-label='${label}']`) as HTMLAnchorElement | null,
|
|
};
|
|
}
|
|
|
|
it("renders a Primary nav with the four destination links", () => {
|
|
const { getNav, getLinks } = renderRail("/NEX/assistant");
|
|
expect(getNav()).not.toBeNull();
|
|
const links = getLinks();
|
|
// The rail also renders a "Nexus home" link for the mark at the top;
|
|
// we filter to just the four destinations below.
|
|
const labels = links
|
|
.map((a) => a.getAttribute("aria-label"))
|
|
.filter((label) => label !== "Nexus home");
|
|
expect(labels).toEqual(["Assistant", "Studio", "Projects", "Settings"]);
|
|
});
|
|
|
|
it("builds company-prefixed URLs for the first three destinations", () => {
|
|
const { getLinkByLabel } = renderRail("/NEX/assistant");
|
|
expect(getLinkByLabel("Assistant")?.getAttribute("href")).toBe("/NEX/assistant");
|
|
expect(getLinkByLabel("Studio")?.getAttribute("href")).toBe("/NEX/content-studio");
|
|
expect(getLinkByLabel("Projects")?.getAttribute("href")).toBe("/NEX/projects");
|
|
});
|
|
|
|
it("points Settings at the global /instance/settings/general route", () => {
|
|
const { getLinkByLabel } = renderRail("/NEX/assistant");
|
|
expect(getLinkByLabel("Settings")?.getAttribute("href")).toBe("/instance/settings/general");
|
|
});
|
|
|
|
it("marks the Assistant link as current on /NEX/assistant", () => {
|
|
const { getLinkByLabel } = renderRail("/NEX/assistant");
|
|
expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBe("page");
|
|
expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBeNull();
|
|
});
|
|
|
|
it("marks the Projects link as current on /NEX/issues", () => {
|
|
const { getLinkByLabel } = renderRail("/NEX/issues");
|
|
expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBe("page");
|
|
expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBeNull();
|
|
});
|
|
|
|
it("marks the Studio link as current on /NEX/convert (Convert is folded into Studio)", () => {
|
|
const { getLinkByLabel } = renderRail("/NEX/convert");
|
|
expect(getLinkByLabel("Studio")?.getAttribute("aria-current")).toBe("page");
|
|
});
|
|
|
|
it("marks the Settings link as current on /instance/settings/general", () => {
|
|
const { getLinkByLabel } = renderRail("/instance/settings/general");
|
|
expect(getLinkByLabel("Settings")?.getAttribute("aria-current")).toBe("page");
|
|
});
|
|
});
|