mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
feat(ui): Settings destination Explorer plumbing (slice 26 Checkpoint A)
Adds the "settings" surface to ExplorerSurface dispatch + new
SettingsExplorer sidebar component + path-to-surface mapping so the
Settings destination joins Topology / Project / Library as a true
Explorer-bearing peer.
NEW packages/ui/src/components/system/SettingsExplorer.tsx:
- Flat sidebar list of 4 items (Settings / Policies / Log / Status)
matching the dispatch's Explorer-item mapping
- Each item is a TanStack Router Link to its sub-route:
/settings, /settings/policies, /settings/log, /settings/status
- Active state derived from current pathname via useRouterState;
/settings is the bare-match for Settings (does NOT trigger sub-route
active state)
- Stable testid surface: settings-explorer, settings-explorer-heading,
settings-explorer-item-{settings|policies|log|status}
MODIFY packages/ui/src/components/Explorer.tsx:
- ExplorerSurface union extends with "settings"
- treeForSurface() dispatch adds `if (surface === "settings") return
<SettingsExplorer />` branch alongside topology/project/specs/for-you
- Existing "none" fallthrough preserved for Dashboard
MODIFY packages/ui/src/components/AppShell.tsx:
- surfaceForPath() adds `if (pathname.startsWith("/settings")) return
"settings"` line. Prior "none" return reachable only for Dashboard.
Tests (TDD; 8 leaf tests):
- 4 items render (HG-2)
- Each item is a Link with the expected href (HG-3)
- Per-route active-state derivation: /settings only activates
Settings; /settings/policies activates Policies; /settings/log
activates Log; /settings/status activates Status
- Explorer root testid + item labels match dispatch
Test infrastructure: tests use createAppTestRouter (vs createTestRouter)
to register all 4 sub-routes so the TanStack Link href + active-state
derivation work at any starting path. waitFor() wraps the synchronous
queries since the test router mounts asynchronously.
Typecheck: touched files clean.
WHAT THIS CHECKPOINT DOES NOT DO (Checkpoint B):
- Register the 3 new routes (/settings/policies, /settings/log,
/settings/status) in routes.tsx
- Refactor SettingsCenter to be route-driven (remove inline tab state
+ render content per active route segment)
- Create PoliciesPage empty-scaffold component
- Remove the top-row tabs from Settings center
SC-29 EXCEPTION declared: NONE (UI presentation-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
f27da1a48a
commit
e6b20d082e
4 files changed
+194
No files matched your search
@@ -237,6 +237,7 @@ function surfaceForPath(pathname: string): ExplorerSurface {
|
||||
if (pathname.startsWith("/project")) return "project";
|
||||
if (pathname.startsWith("/specs") || pathname.startsWith("/plugins")) return "specs";
|
||||
if (pathname.startsWith("/for-you")) return "for-you";
|
||||
if (pathname.startsWith("/settings")) return "settings";
|
||||
return "none";
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { EmptyState } from "./ui/empty-state.js";
|
||||
import { ProjectTreeView } from "./project/ProjectTreeView.js";
|
||||
import { SpecsTreeView } from "./specs/SpecsTreeView.js";
|
||||
import { SettingsExplorer } from "./system/SettingsExplorer.js";
|
||||
import { TopologyTreeView } from "./topology/TopologyTreeView.js";
|
||||
import { SubscriptionToggleList } from "./for-you/SubscriptionToggleList.js";
|
||||
|
||||
@@ -33,6 +34,7 @@ export type ExplorerSurface =
|
||||
| "project"
|
||||
| "specs"
|
||||
| "for-you"
|
||||
| "settings"
|
||||
| "none";
|
||||
|
||||
interface ExplorerProps {
|
||||
@@ -214,6 +216,9 @@ function SurfaceBody({
|
||||
if (surface === "specs") {
|
||||
return <SpecsTreeView />;
|
||||
}
|
||||
if (surface === "settings") {
|
||||
return <SettingsExplorer />;
|
||||
}
|
||||
if (surface === "for-you") {
|
||||
// Subscription affordance — settings-shaped surface per for-you-feed.md L134-L140.
|
||||
// The PRIMARY UX of /for-you is the FEED in the center; subscriptions live
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Slice 26 — Settings destination Explorer sidebar.
|
||||
//
|
||||
// Renders the 4 Settings destinations as a flat sidebar list (peer to
|
||||
// Topology / Project / Library / For-You destinations per dispatch).
|
||||
// Each item is a TanStack Router Link to its sub-route. The active
|
||||
// item is derived from the current router pathname so the sidebar
|
||||
// stays in sync regardless of how the user navigated.
|
||||
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
interface SettingsExplorerItem {
|
||||
id: "settings" | "policies" | "log" | "status";
|
||||
label: string;
|
||||
href: string;
|
||||
/**
|
||||
* Predicate matching the current pathname to "active" state.
|
||||
* The Settings root is the exact match `/settings`; sub-routes
|
||||
* match their full path.
|
||||
*/
|
||||
isActive: (pathname: string) => boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_ITEMS: SettingsExplorerItem[] = [
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
href: "/settings",
|
||||
// Active for the bare /settings only — not for /settings/<sub>.
|
||||
isActive: (path) => path === "/settings",
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
label: "Policies",
|
||||
href: "/settings/policies",
|
||||
isActive: (path) => path.startsWith("/settings/policies"),
|
||||
},
|
||||
{
|
||||
id: "log",
|
||||
label: "Log",
|
||||
href: "/settings/log",
|
||||
isActive: (path) => path.startsWith("/settings/log"),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
href: "/settings/status",
|
||||
isActive: (path) => path.startsWith("/settings/status"),
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsExplorer() {
|
||||
const routerState = useRouterState();
|
||||
const pathname = routerState.location.pathname;
|
||||
|
||||
return (
|
||||
<div data-testid="settings-explorer" className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 mb-2">
|
||||
<span
|
||||
data-testid="settings-explorer-heading"
|
||||
className="block font-mono text-[11px] uppercase tracking-wide text-stone-900 px-2 py-1"
|
||||
>
|
||||
{"> "}Settings
|
||||
</span>
|
||||
</div>
|
||||
<ul className="px-2 space-y-0.5">
|
||||
{SETTINGS_ITEMS.map((item) => {
|
||||
const active = item.isActive(pathname);
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
to={item.href}
|
||||
data-testid={`settings-explorer-item-${item.id}`}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
"block font-mono text-[11px] uppercase tracking-wide px-2 py-1",
|
||||
active
|
||||
? "bg-stone-900 text-stone-50"
|
||||
: "text-stone-700 hover:text-stone-900 hover:bg-surface-low",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Slice 26 Checkpoint A — SettingsExplorer (sidebar) tests.
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { SettingsExplorer } from "../src/components/system/SettingsExplorer.js";
|
||||
import { createAppTestRouter } from "./helpers/test-router.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function renderAt(initialPath: string) {
|
||||
// All 4 Settings sub-routes mount the SettingsExplorer so the Link
|
||||
// active-state derivation can be exercised at any of them.
|
||||
const explorerComponent = () => <SettingsExplorer />;
|
||||
return render(
|
||||
createAppTestRouter({
|
||||
initialPath,
|
||||
routes: [
|
||||
{ path: "/settings", component: explorerComponent },
|
||||
{ path: "/settings/policies", component: explorerComponent },
|
||||
{ path: "/settings/log", component: explorerComponent },
|
||||
{ path: "/settings/status", component: explorerComponent },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsExplorer", () => {
|
||||
it("renders 4 items: Settings / Policies / Log / Status (HG-2)", async () => {
|
||||
renderAt("/settings");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-settings")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("settings-explorer-item-policies")).toBeTruthy();
|
||||
expect(screen.getByTestId("settings-explorer-item-log")).toBeTruthy();
|
||||
expect(screen.getByTestId("settings-explorer-item-status")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("each item is a navigation link with the expected href", async () => {
|
||||
renderAt("/settings");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-settings")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("settings-explorer-item-settings").getAttribute("href")).toBe("/settings");
|
||||
expect(screen.getByTestId("settings-explorer-item-policies").getAttribute("href")).toBe("/settings/policies");
|
||||
expect(screen.getByTestId("settings-explorer-item-log").getAttribute("href")).toBe("/settings/log");
|
||||
expect(screen.getByTestId("settings-explorer-item-status").getAttribute("href")).toBe("/settings/status");
|
||||
});
|
||||
|
||||
it("on /settings: Settings item is marked active (data-active=true)", async () => {
|
||||
renderAt("/settings");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-settings").getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
expect(screen.getByTestId("settings-explorer-item-policies").getAttribute("data-active")).toBe("false");
|
||||
});
|
||||
|
||||
it("on /settings/policies: Policies item is marked active", async () => {
|
||||
renderAt("/settings/policies");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-policies").getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
expect(screen.getByTestId("settings-explorer-item-settings").getAttribute("data-active")).toBe("false");
|
||||
});
|
||||
|
||||
it("on /settings/log: Log item is marked active", async () => {
|
||||
renderAt("/settings/log");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-log").getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("on /settings/status: Status item is marked active", async () => {
|
||||
renderAt("/settings/status");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-status").getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("explorer root has discoverable testid for downstream selectors", async () => {
|
||||
renderAt("/settings");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("item labels are human-readable + match dispatch (Settings / Policies / Log / Status)", async () => {
|
||||
renderAt("/settings");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-explorer-item-settings")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("settings-explorer-item-settings").textContent).toMatch(/settings/i);
|
||||
expect(screen.getByTestId("settings-explorer-item-policies").textContent).toMatch(/policies/i);
|
||||
expect(screen.getByTestId("settings-explorer-item-log").textContent).toMatch(/log/i);
|
||||
expect(screen.getByTestId("settings-explorer-item-status").textContent).toMatch(/status/i);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user