feat(settings): one page per section with a grouped nav and search
Settings moves from a tab bar to /settings/<section>: a left nav grouped General / Agents / Routing / Git / Projects (a select on narrow screens), a search box over every section (Ctrl/Cmd+F, Enter opens the first match), and row primitives. Each AI tool has its page under agents/<id>, the AI Tools modal becomes the Agents page, Pricing shows every vendor, Git → Worktrees sets which untracked worktrees the sidebar lists, and each project gets a page with its own worktree override and Forget all. ?tool= and #ai-tools links redirect to the path form.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @file SettingsHub.test.tsx
|
||||
* @description The Settings page shell: a path picks its section, `/settings`
|
||||
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
|
||||
* unknown section falls back to General, a disabled tool has no page while an
|
||||
* installed, enabled catalog tool gets one, a project gets its page, the mobile
|
||||
* select navigates, and the search filters the nav, opens the first match on
|
||||
* Enter, clears on Escape and is focused by Ctrl+F.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { AiTool, AiToolsView } from "../../lib/api";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
vi.mock("../Settings", () => ({
|
||||
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
|
||||
}));
|
||||
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
|
||||
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
|
||||
vi.mock("../AiToolConfig", () => ({
|
||||
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
|
||||
}));
|
||||
vi.mock("../AiRouterConfig", () => ({ AiRouterConfig: () => null }));
|
||||
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
|
||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
||||
}));
|
||||
|
||||
const tool = (id: string, over: Partial<AiTool> = {}) =>
|
||||
({
|
||||
id,
|
||||
label: id[0]!.toUpperCase() + id.slice(1),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
launcher: null,
|
||||
...over,
|
||||
}) as AiTool;
|
||||
let view: AiToolsView;
|
||||
let projects: Project[];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||
projects: { list: vi.fn(async () => ({ projects })) },
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { SettingsHub } from "../SettingsHub";
|
||||
import { __resetAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
|
||||
function renderAt(at: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[at]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings/*"
|
||||
element={
|
||||
<>
|
||||
<SettingsHub />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
|
||||
|
||||
beforeEach(() => {
|
||||
__resetAiTools();
|
||||
view = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", { launcher: "claude" }),
|
||||
tool("codex", { launcher: "codex" }),
|
||||
tool("gemini"),
|
||||
tool("qwen", { enabled: false }),
|
||||
tool("aider", { installed: false }),
|
||||
],
|
||||
};
|
||||
projects = [
|
||||
{ root: "/r/app", name: "app", workspaces: [], worktrees: [] },
|
||||
{ root: null, name: "Folders", workspaces: [], worktrees: [] },
|
||||
];
|
||||
});
|
||||
|
||||
describe("SettingsHub", () => {
|
||||
it("renders the section its path names, marked current in the nav", async () => {
|
||||
renderAt("/settings/appearance");
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(within(nav()).getByRole("link", { name: "Appearance" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page"
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/settings", "/settings/general"],
|
||||
["/settings?tool=codex", "/settings/agents/codex"],
|
||||
["/settings?tool=claude-code", "/settings/agents/claude-code"],
|
||||
["/settings?tool=gemini", "/settings/agents/gemini"],
|
||||
["/settings#ai-tools", "/settings/agents"],
|
||||
])("redirects %s to %s", async (from, to) => {
|
||||
renderAt(from);
|
||||
await screen.findByRole("heading", { level: 1 });
|
||||
expect(screen.getByTestId("where").textContent).toBe(to);
|
||||
});
|
||||
|
||||
it("sends an unknown section to General once the lists have loaded", async () => {
|
||||
renderAt("/settings/nope");
|
||||
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
|
||||
});
|
||||
|
||||
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
|
||||
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
|
||||
renderAt("/settings/agents/gemini");
|
||||
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).toContain("Claude Code");
|
||||
expect(links).not.toContain("Codex");
|
||||
expect(links).not.toContain("Qwen");
|
||||
expect(links).not.toContain("Aider");
|
||||
});
|
||||
|
||||
it("lists a project under Projects and opens its page", async () => {
|
||||
renderAt(`/settings/projects/${encodeURIComponent("/r/app")}`);
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "app" })).toBeInTheDocument();
|
||||
expect(screen.getByText("/r/app")).toBeInTheDocument();
|
||||
expect(within(nav()).queryByText("Folders")).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
|
||||
target: { value: "pricing" },
|
||||
});
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/pricing");
|
||||
expect(screen.getByTestId("pricing-openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
await user.type(box, "pricing");
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
|
||||
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
|
||||
"data-first-match",
|
||||
"true"
|
||||
);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/pricing");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(box).toHaveValue("");
|
||||
expect(within(nav()).getAllByRole("link").length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
expect(box).toHaveFocus();
|
||||
await user.type(box, "zzzz");
|
||||
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user