mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Separate built-in routines on routines page
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d830610293
commit
344c6cf4fa
2 files changed
+142
-19
No files matched your search
@@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Routines, buildRoutineGroups, sortRoutines } from "./Routines";
|
||||
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
|
||||
|
||||
let currentSearch = "";
|
||||
|
||||
@@ -376,6 +376,28 @@ describe("Routines page", () => {
|
||||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
||||
});
|
||||
|
||||
it("keeps built-in routines in their own section before configured groups", () => {
|
||||
const groups = buildRoutineSections(
|
||||
[
|
||||
createRoutine({
|
||||
id: "routine-1",
|
||||
title: "Reflection review",
|
||||
projectId: "project-1",
|
||||
originKind: "built_in_agent_bundle",
|
||||
originId: "reflection-coach:recent-agent-reflection",
|
||||
}),
|
||||
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
||||
],
|
||||
"project",
|
||||
new Map([["project-1", { name: "Project Alpha" }]]),
|
||||
new Map([["agent-1", { name: "Agent One" }]]),
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual(["Built-in routines", "Project Alpha"]);
|
||||
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
|
||||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Morning sync"]);
|
||||
});
|
||||
|
||||
it("sorts routines by selected field and direction without mutating the source list", () => {
|
||||
const routines = [
|
||||
createRoutine({
|
||||
@@ -508,6 +530,55 @@ describe("Routines page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders built-in routines in a dedicated section on the routines tab", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({
|
||||
id: "routine-1",
|
||||
title: "Morning sync",
|
||||
projectId: "project-1",
|
||||
}),
|
||||
createRoutine({
|
||||
id: "routine-2",
|
||||
title: "Reflection review",
|
||||
projectId: null,
|
||||
originKind: "built_in_agent_bundle",
|
||||
originId: "reflection-coach:recent-agent-reflection",
|
||||
}),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Built-in routines"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review"));
|
||||
expect(text.indexOf("Reflection review")).toBeLessThan(text.indexOf("Project Alpha"));
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides archived routines from the routines list", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
||||
|
||||
+70
-18
@@ -82,6 +82,8 @@ type RoutineGroup = {
|
||||
items: RoutineListItem[];
|
||||
};
|
||||
|
||||
const builtInRoutineGroupKey = "__built_in_routines";
|
||||
|
||||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "title",
|
||||
sortDir: "asc",
|
||||
@@ -170,6 +172,38 @@ export function buildRoutineGroups(
|
||||
}));
|
||||
}
|
||||
|
||||
export function isBuiltInRoutine(routine: Pick<RoutineListItem, "originKind">) {
|
||||
return routine.originKind === "built_in_agent_bundle";
|
||||
}
|
||||
|
||||
export function buildRoutineSections(
|
||||
routines: RoutineListItem[],
|
||||
groupByValue: RoutineGroupBy,
|
||||
projectById: Map<string, { name: string }>,
|
||||
agentById: Map<string, { name: string }>,
|
||||
): RoutineGroup[] {
|
||||
const builtInRoutines = routines.filter(isBuiltInRoutine);
|
||||
const customRoutines = routines.filter((routine) => !isBuiltInRoutine(routine));
|
||||
const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById)
|
||||
.filter((group) => group.items.length > 0)
|
||||
.map((group) => (
|
||||
builtInRoutines.length > 0 && groupByValue === "none" && group.key === "__all"
|
||||
? { ...group, label: "Custom routines" }
|
||||
: group
|
||||
));
|
||||
|
||||
if (builtInRoutines.length === 0) return customGroups;
|
||||
|
||||
return [
|
||||
{
|
||||
key: builtInRoutineGroupKey,
|
||||
label: "Built-in routines",
|
||||
items: builtInRoutines,
|
||||
},
|
||||
...customGroups,
|
||||
];
|
||||
}
|
||||
|
||||
export function sortRoutines(
|
||||
routines: RoutineListItem[],
|
||||
sortField: RoutineSortField,
|
||||
@@ -199,6 +233,34 @@ function buildRoutinesTabHref(tab: RoutinesTab) {
|
||||
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
||||
}
|
||||
|
||||
function RoutineSectionHeader({
|
||||
label,
|
||||
count,
|
||||
isOpen,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
|
||||
isOpen ? " mb-1" : ""
|
||||
}`}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Routines() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -425,8 +487,8 @@ export function Routines() {
|
||||
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
|
||||
);
|
||||
const routineGroups = useMemo(
|
||||
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
const routineSections = useMemo(
|
||||
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
|
||||
);
|
||||
const recentRunsIssueLinkState = useMemo(
|
||||
@@ -886,7 +948,7 @@ export function Routines() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{routineGroups.map((group) => {
|
||||
{routineSections.map((group) => {
|
||||
const isOpen = !routineViewState.collapsedGroups.includes(group.key);
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -901,21 +963,11 @@ export function Routines() {
|
||||
}}
|
||||
>
|
||||
{group.label ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
|
||||
isOpen ? " mb-1" : ""
|
||||
}`}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<RoutineSectionHeader
|
||||
label={group.label}
|
||||
count={group.items.length}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((routine) => (
|
||||
|
||||
Reference in new issue
Block a user