Skip to content
Merged
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
9 changes: 9 additions & 0 deletions apps/portal/src/components/Layout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export const Collapsed: Story = {
await expect(
canvas.getByText(/This is an AI evaluation platform\./),
).toBeVisible();
await expect(
canvas.getByRole("link", { name: "Runs" }).querySelector(".lucide-list"),
).toBeTruthy();
await expect(
canvas.getByRole("link", { name: "Reports" }).querySelector(".lucide-file-text"),
).toBeTruthy();
},
};

Expand All @@ -51,6 +57,9 @@ export const Expanded: Story = {
await expect(canvas.getByRole("button", { name: "Collapse sidebar" })).toBeVisible();
// With a project selected, scoped nav is present.
await expect(canvas.getByRole("link", { name: "Runs" })).toBeVisible();
await expect(
canvas.getByRole("link", { name: "Runs" }).querySelector(".lucide-list"),
).toBeTruthy();
},
};

Expand Down
32 changes: 32 additions & 0 deletions apps/portal/src/components/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,38 @@ afterEach(() => {
});

describe("Layout", () => {
it.each(["0", "1"])("preserves section icons with sidebar expanded=%s", (expanded) => {
localStorage.setItem("scope:layout:sidebar-expanded", expanded);
renderLayout();

const sidebar = within(screen.getByLabelText("Primary navigation"));
const sections = [
["Projects", "folder-kanban"],
["Runs", "list"],
["Statistics", "chart-column"],
["Reports", "file-text"],
["Insights", "lightbulb"],
["Prompts", "message-square-text"],
["Criteria", "flask-conical"],
["Features", "tags"],
["Profiles", "sliders-horizontal"],
["Skills", "book-open"],
["Codebases", "folder-git2"],
["Resources", "boxes"],
["MCP", "server"],
["Extensions", "puzzle"],
["Agents", "bot"],
["Models", "cpu"],
["Secrets", "key-round"],
];
for (const [label, icon] of sections) {
expect(
sidebar.getByRole("link", { name: label }).querySelector(`.lucide-${icon}`),
label,
).toBeTruthy();
}
});

it("keeps the full-bleed shell when opening and closing a key preview", () => {
renderLayout("/secrets/keys");

Expand Down
52 changes: 18 additions & 34 deletions apps/portal/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,10 @@ import { cn } from "@/lib/utils";
import {
Activity,
Plus,
List,
FlaskConical,
BarChart3,
Tags,
FileText,
KeyRound,
Bot,
Server,
Lightbulb,
Cpu,
MessageSquareText,
Settings,
Menu,
BookOpen,
FolderGit2,
FolderKanban,
GitBranch,
Plug,
Puzzle,
Boxes,
SlidersHorizontal,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
Expand All @@ -50,6 +33,7 @@ import { UserMenu } from "./UserMenu";
import { ProjectSwitcher } from "./ProjectSwitcher";
import { useFeatureFlags } from "@/contexts/FeatureFlagContext";
import { useProjectContext } from "@/contexts/ProjectContext";
import { navigationIcons } from "./navigation-icons";

interface NavItem {
to: string;
Expand Down Expand Up @@ -94,33 +78,33 @@ const navGroups: NavGroup[] = [
label: "Activity",
scoped: true,
items: [
{ to: "/runs", label: "Runs", icon: List },
{ to: "/statistics", label: "Statistics", icon: BarChart3 },
{ to: "/reports", label: "Reports", icon: FileText },
{ to: "/insights", label: "Insights", icon: Lightbulb },
{ to: "/runs", label: "Runs", icon: navigationIcons.runs },
{ to: "/statistics", label: "Statistics", icon: navigationIcons.statistics },
{ to: "/reports", label: "Reports", icon: navigationIcons.reports },
{ to: "/insights", label: "Insights", icon: navigationIcons.insights },
],
},
{
id: "library",
label: "Library",
scoped: true,
items: [
{ to: "/task-prompts", label: "Prompts", icon: MessageSquareText },
{ to: "/criteria", label: "Criteria", icon: FlaskConical },
{ to: "/prompt-features", label: "Features", icon: Tags },
{ to: "/profiles", label: "Profiles", icon: SlidersHorizontal, featureKey: "profiles" },
{ to: "/skills", label: "Skills", icon: BookOpen, featureKey: "skills" },
{ to: "/codebases", label: "Codebases", icon: FolderGit2 },
{ to: "/task-prompts", label: "Prompts", icon: navigationIcons.prompts },
{ to: "/criteria", label: "Criteria", icon: navigationIcons.criteria },
{ to: "/prompt-features", label: "Features", icon: navigationIcons.features },
{ to: "/profiles", label: "Profiles", icon: navigationIcons.profiles, featureKey: "profiles" },
{ to: "/skills", label: "Skills", icon: navigationIcons.skills, featureKey: "skills" },
{ to: "/codebases", label: "Codebases", icon: navigationIcons.codebases },
],
},
{
id: "resources",
label: "Integrations",
scoped: true,
items: [
{ to: "/resources", label: "Resources", icon: Boxes },
{ to: "/mcp-servers", label: "MCP", icon: Server, featureKey: "mcp" },
{ to: "/extensions", label: "Extensions", icon: Puzzle, featureKey: "extensions" },
{ to: "/resources", label: "Resources", icon: navigationIcons.resources },
{ to: "/mcp-servers", label: "MCP", icon: navigationIcons.mcp, featureKey: "mcp" },
{ to: "/extensions", label: "Extensions", icon: navigationIcons.extensions, featureKey: "extensions" },
],
},
{
Expand All @@ -129,9 +113,9 @@ const navGroups: NavGroup[] = [
id: "platform",
label: "Platform",
items: [
{ to: "/agents", label: "Agents", icon: Bot, featureKey: "agents" },
{ to: "/models", label: "Models", icon: Cpu, featureKey: "models" },
{ to: "/secrets", label: "Secrets", icon: KeyRound, featureKey: "tokens" },
{ to: "/agents", label: "Agents", icon: navigationIcons.agents, featureKey: "agents" },
{ to: "/models", label: "Models", icon: navigationIcons.models, featureKey: "models" },
{ to: "/secrets", label: "Secrets", icon: navigationIcons.secrets, featureKey: "tokens" },
],
},
];
Expand Down Expand Up @@ -394,7 +378,7 @@ export function Layout() {
<SidebarIconLink
to="/projects"
label="Projects"
icon={FolderKanban}
icon={navigationIcons.projects}
active={location.pathname.startsWith("/projects")}
expanded={sidebarExpanded}
/>
Expand Down
66 changes: 66 additions & 0 deletions apps/portal/src/components/list-layout/ListLayout.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent } from "storybook/test";
import { ListLayout } from "./ListLayout";

const meta = {
title: "Layout/ListLayout",
component: ListLayout,
tags: ["ai-generated", "needs-work"],
parameters: {
layout: "fullscreen",
},
args: {
title: "Runs",
description: "Benchmark runs and processing history",
filterRail: <div className="p-4">Run filters</div>,
railStorageKey: "runs",
children: <div>Run table</div>,
},
} satisfies Meta<typeof ListLayout>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Runs: Story = {
play: async ({ canvas }) => {
const trigger = canvas.getByLabelText("Open filters");
await expect(trigger.querySelector(".lucide-list")).toBeTruthy();
await expect(trigger).toHaveAttribute("title", "Open filters");

if (window.matchMedia("(max-width: 1023.98px)").matches) {
await userEvent.click(trigger);
await expect(screen.getByRole("dialog", { name: "Filters" })).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Close" }));
}
},
};

export const Reports: Story = {
args: {
title: "Reports",
description: "Generated benchmark reports",
filterRail: <div className="p-4">Report filters</div>,
railStorageKey: "reports",
children: <div>Report table</div>,
},
play: async ({ canvas }) => {
await expect(
canvas.getByLabelText("Open filters").querySelector(".lucide-file-text"),
).toBeTruthy();
},
};

export const UnknownSection: Story = {
args: {
title: "Custom list",
railStorageKey: "custom-list",
},
play: async ({ canvas }) => {
await expect(
canvas.getByLabelText("Open filters").querySelector(".lucide-sliders-horizontal"),
).toBeTruthy();
},
};
84 changes: 81 additions & 3 deletions apps/portal/src/components/list-layout/ListLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
// Licensed under the MIT License.

// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { composeStories } from "@storybook/react-vite";
import { useIsCompactViewport } from "@/hooks/useMediaQuery";
import { ListLayout } from "./ListLayout";
import * as stories from "./ListLayout.stories";

vi.mock("@/hooks/useMediaQuery", () => ({
useIsCompactViewport: () => false,
useIsCompactViewport: vi.fn(() => false),
}));

beforeEach(() => {
vi.mocked(useIsCompactViewport).mockReturnValue(false);
});

afterEach(() => {
cleanup();
localStorage.clear();
Expand All @@ -30,12 +37,75 @@ describe("ListLayout", () => {

expect(screen.getByText("Runs")).toBeTruthy();
expect(screen.getByText("table-content")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Open filters" }).querySelector(".lucide-list"),
).toBeTruthy();
expect(localStorage.getItem("scope:list-layout:runs:rail-collapsed")).toBe("0");

fireEvent.click(screen.getByRole("button", { name: "Hide filters" }));
expect(localStorage.getItem("scope:list-layout:runs:rail-collapsed")).toBe("1");
});

it("updates the section icon when the list changes", () => {
const { rerender } = render(
<ListLayout title="Runs" filterRail={<div>filters</div>} railStorageKey="runs">
<div>table-content</div>
</ListLayout>,
);
expect(
screen.getByRole("button", { name: "Open filters" }).querySelector(".lucide-list"),
).toBeTruthy();

rerender(
<ListLayout title="Reports" filterRail={<div>filters</div>} railStorageKey="reports">
<div>table-content</div>
</ListLayout>,
);
expect(
screen.getByRole("button", { name: "Open filters" }).querySelector(".lucide-file-text"),
).toBeTruthy();
});

it.each([undefined, "", "custom-list", "__proto__", "constructor", "toString"])(
"preserves the generic filter icon for storage key %s",
(railStorageKey) => {
render(
<ListLayout title="Custom list" filterRail={<div>filters</div>} railStorageKey={railStorageKey}>
<div>table-content</div>
</ListLayout>,
);
expect(
screen.getByRole("button", { name: "Open filters" }).querySelector(".lucide-sliders-horizontal"),
).toBeTruthy();
},
);

it("opens the compact filter sheet from the section icon", () => {
vi.mocked(useIsCompactViewport).mockReturnValue(true);
render(
<ListLayout title="Reports" filterRail={<div>filters</div>} railStorageKey="reports">
<div>table-content</div>
</ListLayout>,
);
const trigger = screen.getByRole("button", { name: "Open filters" });
expect(trigger.getAttribute("title")).toBe("Open filters");
expect(trigger.querySelector(".lucide-file-text")).toBeTruthy();
fireEvent.click(trigger);
expect(screen.getByRole("dialog", { name: "Filters" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog", { name: "Filters" })).toBeNull();
});

it("does not add filter controls when there is no rail", () => {
render(
<ListLayout title="Reports" filterRail={null} railStorageKey="reports">
<div>table-content</div>
</ListLayout>,
);
expect(screen.queryByRole("button", { name: "Open filters" })).toBeNull();
expect(screen.queryByRole("button", { name: "Hide filters" })).toBeNull();
});

it("honors stored collapsed state on first render", () => {
localStorage.setItem("scope:list-layout:runs:rail-collapsed", "1");

Expand All @@ -53,3 +123,11 @@ describe("ListLayout", () => {
});
});

describe("ListLayout stories", () => {
for (const [name, Story] of Object.entries(composeStories(stories))) {
it(`${name} renders and play passes`, async () => {
const { container } = render(<Story />);
await Story.play?.({ canvas: within(container), canvasElement: container });
});
}
});
6 changes: 4 additions & 2 deletions apps/portal/src/components/list-layout/ListLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/components/ui/sheet";
import { useIsCompactViewport } from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
import { getListSectionIcon } from "@/components/navigation-icons";

export interface ListLayoutProps {
/** Page title (rendered above the table). */
Expand Down Expand Up @@ -97,6 +98,7 @@ export function ListLayout({
const detailVisible = detail !== null && detail !== undefined && detail !== false;
const secondaryVisible = secondaryPanel !== null && secondaryPanel !== undefined && secondaryPanel !== false;
const hasRail = filterRail !== null;
const SectionIcon = getListSectionIcon(railStorageKey) ?? SlidersHorizontal;

return (
<div className={cn("flex h-full min-h-0 w-full overflow-hidden", className)}>
Expand Down Expand Up @@ -150,9 +152,9 @@ export function ListLayout({
className="h-8 w-8 shrink-0 lg:hidden"
onClick={() => setMobileRailOpen(true)}
aria-label="Open filters"
title="Filters"
title="Open filters"
>
<SlidersHorizontal className="h-4 w-4" />
<SectionIcon className="h-4 w-4" />
</Button>
</>
)}
Expand Down
Loading
Loading