Group workspace routines by project

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 23:31:04 +00:00
1 parent de837e2683
commit b22f8d3a8d
4 files changed
+201 -20

No files matched your search

+43
View File
@@ -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],
});
});
});
+30
View File
@@ -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),
};
}
+74 -1
View File
@@ -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");
});
});
+54 -19
View File
@@ -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>