mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
Fix project workspace changes tab
This commit is contained in:
1 parent
f097fe4937
commit
bf7460227d
2 files changed
+282
-46
No files matched your search
@@ -0,0 +1,235 @@
|
||||
// @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: "" }));
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{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);
|
||||
return {
|
||||
slots: [
|
||||
{
|
||||
id: "workspace-changes-tab",
|
||||
type: "detailTab",
|
||||
displayName: "Changes",
|
||||
exportName: "WorkspaceChangesTab",
|
||||
entityTypes: ["project_workspace"],
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "paperclip.workspace-diff",
|
||||
pluginDisplayName: "Workspace Diff",
|
||||
pluginVersion: "0.1.0",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
};
|
||||
},
|
||||
}));
|
||||
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",
|
||||
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));
|
||||
}
|
||||
|
||||
describe("ProjectWorkspaceDetail changes tab", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockProjectsApi.get.mockResolvedValue(project());
|
||||
});
|
||||
|
||||
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("shows Changes as a first-class tab and maps the legacy plugin tab URL to it", async () => {
|
||||
mockRouteSearch.value =
|
||||
"?tab=plugin%3Apaperclip.workspace-diff%3Aworkspace-changes-tab&diffView=head&baseRef=origin%2Fmaster";
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration");
|
||||
expect(container.querySelector('[data-tab-value="changes"]')?.textContent).toBe("Changes");
|
||||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
|
||||
expect(mockPluginSlotMount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slot: expect.objectContaining({ pluginKey: "paperclip.workspace-diff", id: "workspace-changes-tab" }),
|
||||
context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the Changes tab in upstream comparison mode", async () => {
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
(container.querySelector('[data-tab-value="changes"]') as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/projects/paperclip-app/workspaces/workspace-1?tab=changes&diffView=head&baseRef=origin%2Fmain",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Check, ExternalLink, GitCompare, Loader2, Sparkles } from "lucide-react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
@@ -38,13 +38,13 @@ type WorkspaceFormState = {
|
||||
|
||||
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
|
||||
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
|
||||
type ProjectWorkspaceBaseTab = "configuration";
|
||||
type ProjectWorkspacePluginTab = `plugin:${string}`;
|
||||
type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab;
|
||||
const WORKSPACE_CHANGES_TAB = "plugin:paperclip.workspace-diff:workspace-changes-tab" as ProjectWorkspacePluginTab;
|
||||
type ProjectWorkspaceTab = "configuration" | "changes";
|
||||
const WORKSPACE_CHANGES_PLUGIN_TAB = "plugin:paperclip.workspace-diff:workspace-changes-tab";
|
||||
|
||||
function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab {
|
||||
return typeof value === "string" && value.startsWith("plugin:");
|
||||
function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab {
|
||||
const tab = new URLSearchParams(search).get("tab");
|
||||
if (tab === "changes" || tab === WORKSPACE_CHANGES_PLUGIN_TAB) return "changes";
|
||||
return "configuration";
|
||||
}
|
||||
|
||||
function upstreamBaseRef(workspace: ProjectWorkspace) {
|
||||
@@ -244,11 +244,7 @@ export function ProjectWorkspaceDetail() {
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const routeProjectRef = projectId ?? "";
|
||||
const routeWorkspaceId = workspaceId ?? "";
|
||||
const pluginTabFromSearch = useMemo(() => {
|
||||
const tab = new URLSearchParams(location.search).get("tab");
|
||||
return isProjectWorkspacePluginTab(tab) ? tab : null;
|
||||
}, [location.search]);
|
||||
const activeTab: ProjectWorkspaceTab = pluginTabFromSearch ?? "configuration";
|
||||
const activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]);
|
||||
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -272,7 +268,11 @@ export function ProjectWorkspaceDetail() {
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||||
const { slots: pluginDetailSlots } = usePluginSlots({
|
||||
const {
|
||||
slots: pluginDetailSlots,
|
||||
isLoading: pluginDetailSlotsLoading,
|
||||
errorMessage: pluginDetailSlotsError,
|
||||
} = usePluginSlots({
|
||||
slotTypes: ["detailTab"],
|
||||
entityType: "project_workspace",
|
||||
companyId: project?.companyId ?? null,
|
||||
@@ -280,13 +280,13 @@ export function ProjectWorkspaceDetail() {
|
||||
});
|
||||
const pluginTabItems = useMemo(
|
||||
() => pluginDetailSlots.map((slot) => ({
|
||||
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab,
|
||||
value: `plugin:${slot.pluginKey}:${slot.id}`,
|
||||
label: slot.displayName,
|
||||
slot,
|
||||
})),
|
||||
[pluginDetailSlots],
|
||||
);
|
||||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||
const changesPluginTab = pluginTabItems.find((item) => item.value === WORKSPACE_CHANGES_PLUGIN_TAB) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
@@ -312,8 +312,8 @@ export function ProjectWorkspaceDetail() {
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (routeProjectRef === canonicalProjectRef) return;
|
||||
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
|
||||
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
|
||||
navigate(`${projectWorkspaceUrl(project, routeWorkspaceId)}${location.search}`, { replace: true });
|
||||
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, navigate]);
|
||||
|
||||
const invalidateProject = () => {
|
||||
if (!project) return;
|
||||
@@ -403,18 +403,15 @@ export function ProjectWorkspaceDetail() {
|
||||
};
|
||||
|
||||
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
|
||||
const changesVsUpstreamUrl = (() => {
|
||||
const params = new URLSearchParams({
|
||||
tab: WORKSPACE_CHANGES_TAB,
|
||||
diffView: "head",
|
||||
baseRef: upstreamBaseRef(workspace),
|
||||
});
|
||||
return `${projectWorkspaceUrl(project, routeWorkspaceId)}?${params.toString()}`;
|
||||
})();
|
||||
const handleTabChange = (tab: ProjectWorkspaceTab) => {
|
||||
const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId);
|
||||
if (isProjectWorkspacePluginTab(tab)) {
|
||||
navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`);
|
||||
if (tab === "changes") {
|
||||
const params = new URLSearchParams({
|
||||
tab: "changes",
|
||||
diffView: "head",
|
||||
baseRef: upstreamBaseRef(workspace),
|
||||
});
|
||||
navigate(`${workspacePath}?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
navigate(workspacePath);
|
||||
@@ -432,19 +429,13 @@ export function ProjectWorkspaceDetail() {
|
||||
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={changesVsUpstreamUrl}>
|
||||
<GitCompare className="mr-1 h-4 w-4" />
|
||||
Changes vs upstream
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
...pluginTabItems.map((item) => ({ value: item.value, label: item.label })),
|
||||
{ value: "changes", label: "Changes" },
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab}
|
||||
@@ -720,18 +711,28 @@ export function ProjectWorkspaceDetail() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activePluginTab ? (
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
context={{
|
||||
companyId: project.companyId,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
projectId: project.id,
|
||||
entityId: workspace.id,
|
||||
entityType: "project_workspace",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
{activeTab === "changes" ? (
|
||||
changesPluginTab ? (
|
||||
<PluginSlotMount
|
||||
slot={changesPluginTab.slot}
|
||||
context={{
|
||||
companyId: project.companyId,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
projectId: project.id,
|
||||
entityId: workspace.id,
|
||||
entityType: "project_workspace",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
|
||||
{pluginDetailSlotsError
|
||||
? `Workspace changes failed to load: ${pluginDetailSlotsError}`
|
||||
: pluginDetailSlotsLoading
|
||||
? "Loading workspace changes..."
|
||||
: "Workspace changes are not available."}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user