mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Group workspace routines by project
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
de837e2683
commit
b22f8d3a8d
4 files changed
+201
-20
No files matched your search
@@ -2,6 +2,7 @@ import type { RoutineListItem } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getWorkspaceSpecificRoutineVariableNames,
|
||||
groupWorkspaceSpecificRoutines,
|
||||
routineHasWorkspaceSpecificVariables,
|
||||
sortWorkspaceRoutinesByName,
|
||||
} from "./workspace-routines";
|
||||
@@ -96,4 +97,46 @@ describe("workspace routine helpers", () => {
|
||||
]);
|
||||
expect(routines.map((routine) => routine.id)).toEqual(["routine-2", "routine-3", "routine-1"]);
|
||||
});
|
||||
|
||||
it("groups current-project workspace routines before other workspace routines and ignores non-workspace routines", () => {
|
||||
const currentRoutine = createRoutine({
|
||||
id: "routine-current",
|
||||
projectId: "project-1",
|
||||
title: "Current review",
|
||||
variables: [
|
||||
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||
],
|
||||
});
|
||||
const otherProjectRoutine = createRoutine({
|
||||
id: "routine-other-project",
|
||||
projectId: "project-2",
|
||||
title: "Beta other review",
|
||||
variables: [
|
||||
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||
],
|
||||
});
|
||||
const noProjectRoutine = createRoutine({
|
||||
id: "routine-no-project",
|
||||
projectId: null,
|
||||
title: "Alpha other review",
|
||||
variables: [
|
||||
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||
],
|
||||
});
|
||||
const generalRoutine = createRoutine({
|
||||
id: "routine-general",
|
||||
projectId: "project-1",
|
||||
variables: [
|
||||
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(groupWorkspaceSpecificRoutines(
|
||||
[otherProjectRoutine, generalRoutine, currentRoutine, noProjectRoutine],
|
||||
"project-1",
|
||||
)).toEqual({
|
||||
thisWorkspace: [currentRoutine],
|
||||
otherWorkspaces: [noProjectRoutine, otherProjectRoutine],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,3 +37,33 @@ export function sortWorkspaceRoutinesByName(routines: RoutineListItem[]): Routin
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export interface WorkspaceRoutineGroups {
|
||||
thisWorkspace: RoutineListItem[];
|
||||
otherWorkspaces: RoutineListItem[];
|
||||
}
|
||||
|
||||
export function groupWorkspaceSpecificRoutines(
|
||||
routines: RoutineListItem[],
|
||||
currentProjectId: string | null,
|
||||
): WorkspaceRoutineGroups {
|
||||
const groups: WorkspaceRoutineGroups = {
|
||||
thisWorkspace: [],
|
||||
otherWorkspaces: [],
|
||||
};
|
||||
|
||||
for (const routine of routines) {
|
||||
if (!routineHasWorkspaceSpecificVariables(routine)) continue;
|
||||
|
||||
if (routine.projectId === currentProjectId) {
|
||||
groups.thisWorkspace.push(routine);
|
||||
} else {
|
||||
groups.otherWorkspaces.push(routine);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
thisWorkspace: sortWorkspaceRoutinesByName(groups.thisWorkspace),
|
||||
otherWorkspaces: sortWorkspaceRoutinesByName(groups.otherWorkspaces),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace, Project } from "@paperclipai/shared";
|
||||
import type { ExecutionWorkspace, Project, RoutineListItem } 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";
|
||||
@@ -181,6 +181,41 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
};
|
||||
}
|
||||
|
||||
function routine(overrides: Partial<RoutineListItem> = {}): RoutineListItem {
|
||||
const now = new Date("2026-05-01T00:00:00Z");
|
||||
return {
|
||||
id: "routine-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Workspace routine",
|
||||
description: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [
|
||||
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||
],
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
triggers: [],
|
||||
lastRun: null,
|
||||
activeIssue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSlot(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "changes-tab",
|
||||
@@ -201,6 +236,16 @@ async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitForCondition(condition: () => boolean) {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
if (condition()) return;
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
expect(condition()).toBe(true);
|
||||
}
|
||||
|
||||
describe("ExecutionWorkspaceDetail plugin slots", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
@@ -319,4 +364,32 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
|
||||
"Default",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads all company routines and renders current and other workspace groups", async () => {
|
||||
mockRouteLocation.pathname = "/execution-workspaces/workspace-1/routines";
|
||||
mockRoutinesApi.list.mockResolvedValue([
|
||||
routine({ id: "routine-current", title: "Current workspace routine", projectId: "project-1" }),
|
||||
routine({ id: "routine-other", title: "Other workspace routine", projectId: "project-2" }),
|
||||
routine({
|
||||
id: "routine-general",
|
||||
title: "General routine",
|
||||
projectId: "project-1",
|
||||
variables: [
|
||||
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await render();
|
||||
|
||||
await waitForCondition(() => container.textContent?.includes("This workspace") === true);
|
||||
|
||||
expect(mockRoutinesApi.list).toHaveBeenCalledWith("company-1");
|
||||
expect(mockRoutinesApi.list).not.toHaveBeenCalledWith("company-1", { projectId: "project-1" });
|
||||
expect(container.textContent).toContain("This workspace");
|
||||
expect(container.textContent).toContain("Current workspace routine");
|
||||
expect(container.textContent).toContain("Other workspaces");
|
||||
expect(container.textContent).toContain("Other workspace routine");
|
||||
expect(container.textContent).not.toContain("General routine");
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
import {
|
||||
getWorkspaceSpecificRoutineVariableNames,
|
||||
routineHasWorkspaceSpecificVariables,
|
||||
sortWorkspaceRoutinesByName,
|
||||
groupWorkspaceSpecificRoutines,
|
||||
} from "../lib/workspace-routines";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
@@ -442,6 +441,37 @@ function WorkspaceRoutineRow({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRoutineGroup({
|
||||
title,
|
||||
routines,
|
||||
runningRoutineId,
|
||||
onRunNow,
|
||||
}: {
|
||||
title: string;
|
||||
routines: RoutineListItem[];
|
||||
runningRoutineId: string | null;
|
||||
onRunNow: (routine: RoutineListItem) => void;
|
||||
}) {
|
||||
if (routines.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<div className="rounded-lg border border-border">
|
||||
{routines.map((routine) => (
|
||||
<WorkspaceRoutineRow
|
||||
key={routine.id}
|
||||
routine={routine}
|
||||
variableNames={getWorkspaceSpecificRoutineVariableNames(routine)}
|
||||
runningRoutineId={runningRoutineId}
|
||||
onRunNow={onRunNow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionWorkspaceRoutinesList({
|
||||
workspace,
|
||||
project,
|
||||
@@ -455,8 +485,8 @@ function ExecutionWorkspaceRoutinesList({
|
||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||
|
||||
const { data: routines, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.routines.list(workspace.companyId, { projectId: workspace.projectId }),
|
||||
queryFn: () => routinesApi.list(workspace.companyId, { projectId: workspace.projectId }),
|
||||
queryKey: queryKeys.routines.list(workspace.companyId),
|
||||
queryFn: () => routinesApi.list(workspace.companyId),
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
@@ -464,10 +494,12 @@ function ExecutionWorkspaceRoutinesList({
|
||||
queryFn: () => agentsApi.list(workspace.companyId),
|
||||
});
|
||||
|
||||
const workspaceRoutines = useMemo(
|
||||
() => sortWorkspaceRoutinesByName((routines ?? []).filter(routineHasWorkspaceSpecificVariables)),
|
||||
[routines],
|
||||
const workspaceRoutineGroups = useMemo(
|
||||
() => groupWorkspaceSpecificRoutines(routines ?? [], workspace.projectId),
|
||||
[routines, workspace.projectId],
|
||||
);
|
||||
const hasWorkspaceRoutines =
|
||||
workspaceRoutineGroups.thisWorkspace.length > 0 || workspaceRoutineGroups.otherWorkspaces.length > 0;
|
||||
|
||||
const runRoutine = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
|
||||
@@ -488,7 +520,7 @@ function ExecutionWorkspaceRoutinesList({
|
||||
onSuccess: async (_, { id }) => {
|
||||
setRunDialogRoutine(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["routines", workspace.companyId] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(workspace.companyId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(workspace.companyId) }),
|
||||
@@ -527,7 +559,7 @@ function ExecutionWorkspaceRoutinesList({
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load routines."}
|
||||
</p>
|
||||
) : workspaceRoutines.length === 0 ? (
|
||||
) : !hasWorkspaceRoutines ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Repeat className="h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -535,16 +567,19 @@ function ExecutionWorkspaceRoutinesList({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border">
|
||||
{workspaceRoutines.map((routine) => (
|
||||
<WorkspaceRoutineRow
|
||||
key={routine.id}
|
||||
routine={routine}
|
||||
variableNames={getWorkspaceSpecificRoutineVariableNames(routine)}
|
||||
runningRoutineId={runningRoutineId}
|
||||
onRunNow={setRunDialogRoutine}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-5">
|
||||
<WorkspaceRoutineGroup
|
||||
title="This workspace"
|
||||
routines={workspaceRoutineGroups.thisWorkspace}
|
||||
runningRoutineId={runningRoutineId}
|
||||
onRunNow={setRunDialogRoutine}
|
||||
/>
|
||||
<WorkspaceRoutineGroup
|
||||
title="Other workspaces"
|
||||
routines={workspaceRoutineGroups.otherWorkspaces}
|
||||
runningRoutineId={runningRoutineId}
|
||||
onRunNow={setRunDialogRoutine}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
Reference in new issue
Block a user