Files
PaperClipAI/ui/src/pages/ProjectWorkspaceDetail.test.tsx
T
scotttongandClaude Opus 4.8 ee196badee PAP-70: project icon infra + ProjectTile renderer + picker (PAP-68 part 3)
Adds the project icon system mirroring the agent icon pattern:

- shared: PROJECT_ICON_NAMES + ProjectIconName (default "folder", curated
  Lucide set), exported from index; tighten project validator icon field
  from loose string to z.enum(PROJECT_ICON_NAMES).
- ui: project-icons.ts (PROJECT_ICONS map + getProjectIcon defaulting to
  folder) and ProjectIconPicker (Popover + search + grid), mirroring the
  agent equivalents.
- ProjectTile gains an optional `icon` prop, rendering getProjectIcon(icon)
  instead of a hard-coded Folder (default unchanged). Color behavior intact.
- Pass icon={project.icon ?? null} at all three tile sites: Projects list,
  SidebarProjects, and the detail header (threaded through ColorPicker).
- Tests: ProjectTile renders chosen icon / falls back to folder for unknown.
- Backfill required Project.icon on test/storybook fixtures left un-updated
  by part 1, restoring a green UI typecheck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:18:54 -07:00

343 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project, ProjectWorkspace } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectWorkspaceDetail } from "./ProjectWorkspaceDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockProjectsApi = vi.hoisted(() => ({
get: vi.fn(),
updateWorkspace: vi.fn(),
controlWorkspaceCommands: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
const mockRouteSearch = vi.hoisted(() => ({ value: "" }));
const mockPluginSlotState = vi.hoisted(() => ({
slots: [] as unknown[],
isLoading: false,
errorMessage: null as string | null,
}));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
useLocation: () => ({
pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1",
search: mockRouteSearch.value,
hash: "",
state: null,
}),
useNavigate: () => mockNavigate,
useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
usePluginSlots: (filters: unknown) => {
mockUsePluginSlots(filters);
const entityType = (filters as { entityType?: string }).entityType;
return {
slots: entityType === "project_workspace" ? mockPluginSlotState.slots : [],
isLoading: mockPluginSlotState.isLoading,
errorMessage: mockPluginSlotState.errorMessage,
};
},
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({
items,
onValueChange,
}: {
items: Array<{ value: string; label: string }>;
onValueChange?: (value: string) => void;
}) => (
<div data-testid="page-tab-bar">
{items.map((item) => (
<button
key={item.value}
data-tab-value={item.value}
type="button"
onClick={() => onValueChange?.(item.value)}
>
{item.label}
</button>
))}
</div>
),
}));
function projectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Primary checkout",
sourceType: "local_path",
cwd: "/tmp/paperclip",
repoUrl: "https://github.com/paperclipai/paperclip",
repoRef: "master",
defaultRef: "origin/main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
runtimeServices: [],
isPrimary: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
const workspace = projectWorkspace();
return {
id: "project-1",
companyId: "company-1",
urlKey: "paperclip-app",
goalId: null,
goalIds: [],
goals: [],
name: "Paperclip App",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
defaultRef: workspace.defaultRef,
repoName: "paperclip",
localFolder: workspace.cwd,
managedFolder: workspace.cwd ?? "/tmp/paperclip",
effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [workspace],
primaryWorkspace: workspace,
managedByPlugin: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
function pluginSlot(overrides: Record<string, unknown> = {}) {
return {
id: "quality-tab",
type: "detailTab",
displayName: "Quality",
exportName: "ProjectWorkspaceQualityTab",
entityTypes: ["project_workspace"],
pluginId: "plugin-1",
pluginKey: "paperclip.quality",
pluginDisplayName: "Quality Plugin",
pluginVersion: "0.1.0",
...overrides,
};
}
describe("ProjectWorkspaceDetail plugin tabs", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockProjectsApi.get.mockResolvedValue(project());
mockPluginSlotState.slots = [];
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = null;
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
mockRouteSearch.value = "";
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ProjectWorkspaceDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
}
it("scopes plugin detail-tab discovery to project_workspace and the project's company", async () => {
await render();
const enabledDetailTabFilters = mockUsePluginSlots.mock.calls
.map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean })
.filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false);
expect(enabledDetailTabFilters.length).toBeGreaterThan(0);
for (const filters of enabledDetailTabFilters) {
expect(filters.entityType).toBe("project_workspace");
expect(filters.companyId).toBe("company-1");
}
});
it("renders an arbitrary project_workspace plugin detail tab from the generic URL value", async () => {
mockPluginSlotState.slots = [pluginSlot()];
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab&diffView=head&baseRef=origin%2Fmaster";
await render();
expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration");
expect(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]')?.textContent).toBe("Quality");
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
expect(mockPluginSlotMount).toHaveBeenCalledWith(
expect.objectContaining({
slot: expect.objectContaining({ pluginKey: "paperclip.quality", id: "quality-tab" }),
context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }),
}),
);
});
it("keeps the project workspace heading visible on plugin tabs", async () => {
mockPluginSlotState.slots = [pluginSlot({ displayName: "Changes" })];
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
await render();
expect(container.querySelector("h1")?.textContent).toBe("Primary checkout");
expect(container.textContent).toContain("Project workspace");
expect(container.textContent).toContain("This is the project’s primary codebase workspace.");
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
expect(container.textContent).not.toContain("Configure the concrete workspace");
expect(container.textContent).not.toContain("Workspace name");
});
it("orders project workspace plugin tabs against built-in tabs by slot order", async () => {
mockPluginSlotState.slots = [
pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }),
pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }),
pluginSlot({ id: "default-tab", displayName: "Default" }),
];
await render();
const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent);
expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]);
});
it("navigates plugin tabs with only the generic plugin tab parameter", async () => {
mockPluginSlotState.slots = [pluginSlot()];
await render();
await act(async () => {
(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]') as HTMLButtonElement).click();
});
expect(mockNavigate).toHaveBeenCalledWith(
"/projects/paperclip-app/workspaces/workspace-1?tab=plugin%3Apaperclip.quality%3Aquality-tab",
);
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("diffView"));
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("baseRef"));
});
it("does not treat the old changes tab query as a core plugin tab", async () => {
mockPluginSlotState.slots = [pluginSlot()];
mockRouteSearch.value = "?tab=changes&diffView=head&baseRef=origin%2Fmain";
await render();
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
expect(container.textContent).toContain("Project workspace");
});
it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => {
mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot";
await render();
expect(container.textContent).toContain("Workspace plugin tab is not available.");
expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe(
"Back to configuration",
);
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
expect(container.textContent).not.toContain("Configure the concrete workspace");
expect(container.textContent).not.toContain("Workspace name");
});
it("shows loading and error states for plugin tab manifests", async () => {
mockPluginSlotState.isLoading = true;
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
await render();
expect(container.textContent).toContain("Loading workspace plugin...");
act(() => root?.unmount());
root = null;
container.innerHTML = "";
vi.clearAllMocks();
mockProjectsApi.get.mockResolvedValue(project());
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = "Plugin manifest failed";
await render();
expect(container.textContent).toContain("Plugin manifest failed");
});
});