From b309aa0fc594f2fb192aea04d45f83f747691a85 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 5 Jul 2026 11:39:02 +0000 Subject: [PATCH] Add folder UI for routines and skills Co-Authored-By: Paperclip --- server/src/__tests__/folders-routes.test.ts | 74 +++ server/src/routes/folders.ts | 38 +- ui/src/api/folders.ts | 36 ++ ui/src/components/RoutineList.tsx | 31 + .../folders/FolderControls.test.tsx | 110 ++++ ui/src/components/folders/FolderControls.tsx | 583 ++++++++++++++++++ ui/src/lib/queryKeys.ts | 3 + ui/src/pages/CompanySkills.tsx | 383 +++++++++++- ui/src/pages/Routines.test.tsx | 44 +- ui/src/pages/Routines.tsx | 335 +++++++++- 10 files changed, 1597 insertions(+), 40 deletions(-) create mode 100644 server/src/__tests__/folders-routes.test.ts create mode 100644 ui/src/api/folders.ts create mode 100644 ui/src/components/folders/FolderControls.test.tsx create mode 100644 ui/src/components/folders/FolderControls.tsx diff --git a/server/src/__tests__/folders-routes.test.ts b/server/src/__tests__/folders-routes.test.ts new file mode 100644 index 00000000000..f0eff113f03 --- /dev/null +++ b/server/src/__tests__/folders-routes.test.ts @@ -0,0 +1,74 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFolderService = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + moveFolder: vi.fn(), + moveItem: vi.fn(), + deleteFolder: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + folderService: () => mockFolderService, + logActivity: mockLogActivity, +})); + +async function createApp() { + vi.resetModules(); + const [{ errorHandler }, { folderRoutes }] = await Promise.all([ + import("../middleware/index.js") as Promise, + import("../routes/folders.js") as Promise, + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", folderRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("folder routes", () => { + beforeEach(() => { + for (const mock of Object.values(mockFolderService)) mock.mockReset(); + mockLogActivity.mockReset(); + }); + + it("routes item moves to the item move handler before the folder reorder route", async () => { + mockFolderService.moveItem.mockResolvedValue({ + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/folders/items/move") + .send({ + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + + expect(res.status).toBe(200); + expect(mockFolderService.moveItem).toHaveBeenCalledWith("company-1", { + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + expect(mockFolderService.moveFolder).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/folders.ts b/server/src/routes/folders.ts index 40ee0acb24a..9d01421fd82 100644 --- a/server/src/routes/folders.ts +++ b/server/src/routes/folders.ts @@ -71,6 +71,25 @@ export function folderRoutes(db: Db) { res.json(updated); }); + router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const moved = await svc.moveItem(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.item_moved", + entityType: req.body.kind === "routine" ? "routine" : "company_skill", + entityId: moved.itemId, + details: { kind: moved.kind, folderId: moved.folderId }, + }); + res.json(moved); + }); + router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => { const companyId = req.params.companyId as string; const folderId = req.params.folderId as string; @@ -95,25 +114,6 @@ export function folderRoutes(db: Db) { res.json(updated); }); - router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => { - const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); - const moved = await svc.moveItem(companyId, req.body); - const actor = getActorInfo(req); - await logActivity(db, { - companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, - action: "folder.item_moved", - entityType: req.body.kind === "routine" ? "routine" : "company_skill", - entityId: moved.itemId, - details: { kind: moved.kind, folderId: moved.folderId }, - }); - res.json(moved); - }); - router.delete("/companies/:companyId/folders/:folderId", async (req, res) => { const companyId = req.params.companyId as string; const folderId = req.params.folderId as string; diff --git a/ui/src/api/folders.ts b/ui/src/api/folders.ts new file mode 100644 index 00000000000..e8eb1752730 --- /dev/null +++ b/ui/src/api/folders.ts @@ -0,0 +1,36 @@ +import type { + CreateFolderRequest, + Folder, + FolderKind, + FolderListResult, + MoveFolderItemRequest, + MoveFolderRequest, + UpdateFolderRequest, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const foldersApi = { + list: (companyId: string, kind: FolderKind) => + api.get(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`), + create: (companyId: string, payload: CreateFolderRequest) => + api.post(`/companies/${encodeURIComponent(companyId)}/folders`, payload), + update: (companyId: string, folderId: string, payload: UpdateFolderRequest) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`, + payload, + ), + moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`, + payload, + ), + moveItem: (companyId: string, payload: MoveFolderItemRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/folders/items/move`, + payload, + ), + delete: (companyId: string, folderId: string) => + api.delete<{ deleted: Folder }>( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`, + ), +}; diff --git a/ui/src/components/RoutineList.tsx b/ui/src/components/RoutineList.tsx index f2cf986cc54..29604651c19 100644 --- a/ui/src/components/RoutineList.tsx +++ b/ui/src/components/RoutineList.tsx @@ -64,6 +64,10 @@ export function RoutineListRow({ disableToggle = false, hideArchiveAction = false, divider = true, + selected = false, + selectMode = false, + extraMenuItems, + onSelectChange, onRunNow, onToggleEnabled, onToggleArchived, @@ -83,6 +87,10 @@ export function RoutineListRow({ hideArchiveAction?: boolean; /** Render a bottom divider between consecutive rows. Off when the group is its own card. */ divider?: boolean; + selected?: boolean; + selectMode?: boolean; + extraMenuItems?: ReactNode; + onSelectChange?: (routine: TRoutine, selected: boolean) => void; onRunNow: (routine: TRoutine) => void; onToggleEnabled: (routine: TRoutine, enabled: boolean) => void; onToggleArchived?: (routine: TRoutine) => void; @@ -102,6 +110,23 @@ export function RoutineListRow({ divider ? " border-b border-border last:border-b-0" : "" }`} > + {selectMode ? ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + onSelectChange?.(routine, event.target.checked)} + /> +
+ ) : null}
{routine.title} @@ -178,6 +203,12 @@ export function RoutineListRow({ > {runningRoutineId === routine.id ? "Running..." : "Run now"} + {extraMenuItems ? ( + <> + + {extraMenuItems} + + ) : null} onToggleEnabled(routine, enabled)} diff --git a/ui/src/components/folders/FolderControls.test.tsx b/ui/src/components/folders/FolderControls.test.tsx new file mode 100644 index 00000000000..0e9a7b3fd08 --- /dev/null +++ b/ui/src/components/folders/FolderControls.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FolderListResult } from "@paperclipai/shared"; +import { + FolderRail, + folderSearchValue, + normalizeFolderSelection, +} from "./FolderControls"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +const folderResult: FolderListResult = { + kind: "routine", + allCount: 4, + unfiledCount: 1, + folders: [ + { + id: "folder-reporting", + companyId: "company-1", + kind: "routine", + name: "Reporting", + color: "#6366f1", + position: 0, + itemCount: 3, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + ], +}; + +describe("FolderControls", () => { + let container: HTMLDivElement; + let root: Root | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container.remove(); + document.body.innerHTML = ""; + }); + + it("normalizes URL selection values for folder persistence", () => { + expect(normalizeFolderSelection(null)).toBe("all"); + expect(normalizeFolderSelection("unfiled")).toBe("unfiled"); + expect(normalizeFolderSelection("folder-reporting")).toBe("folder-reporting"); + expect(folderSearchValue("all")).toBe(""); + expect(folderSearchValue("unfiled")).toBe("unfiled"); + expect(folderSearchValue("folder-reporting")).toBe("folder-reporting"); + }); + + it("renders All, user folders, and Unfiled with counts and selection callbacks", () => { + const onSelect = vi.fn(); + root = createRoot(container); + + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("All routines"); + expect(container.textContent).toContain("Reporting"); + expect(container.textContent).toContain("Unfiled"); + expect(container.textContent).toContain("4"); + expect(container.textContent).toContain("3"); + expect(container.textContent).toContain("1"); + + const reportingButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Reporting"), + ); + expect(reportingButton).toBeTruthy(); + + act(() => { + reportingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onSelect).toHaveBeenCalledWith("folder-reporting"); + }); +}); diff --git a/ui/src/components/folders/FolderControls.tsx b/ui/src/components/folders/FolderControls.tsx new file mode 100644 index 00000000000..4360f9c1ea6 --- /dev/null +++ b/ui/src/components/folders/FolderControls.tsx @@ -0,0 +1,583 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Folder as FolderIcon, + MoreHorizontal, + Plus, + Search, + Trash2, +} from "lucide-react"; +import type { FolderKind, FolderListItem, FolderListResult } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +export type FolderSelection = "all" | "unfiled" | string; + +export const FOLDER_COLORS = [ + "#6366f1", + "#8b5cf6", + "#10b981", + "#06b6d4", + "#f59e0b", + "#64748b", +]; + +export function normalizeFolderSelection(value: string | null | undefined): FolderSelection { + if (!value) return "all"; + if (value === "unfiled") return "unfiled"; + return value; +} + +export function folderSearchValue(selection: FolderSelection): string { + return selection === "all" ? "" : selection === "unfiled" ? "unfiled" : selection; +} + +export function selectedFolderFromList( + folders: FolderListItem[], + selection: FolderSelection, +): FolderListItem | null { + if (selection === "all" || selection === "unfiled") return null; + return folders.find((folder) => folder.id === selection) ?? null; +} + +export function FolderSwatch({ + color, + className, +}: { + color: string | null | undefined; + className?: string; +}) { + return ( +
{card.forkedFrom ? ( @@ -813,7 +889,7 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco ) : null}
- + ); } @@ -884,6 +960,24 @@ export function DiscoveryGrid({ onScan, scanPending, scanStatus, + folderResult, + folderSelection = "all", + foldersLoading = false, + selectMode = false, + selectedSkillIds = [], + onFolderSelect, + onCreateFolder, + onRenameFolder, + onEditFolder, + onDeleteFolder, + onToggleSelectMode, + onSelectCard, + onMoveCard, + onCreateFolderAndMoveCard, + onMoveSelected, + onCreateFolderAndMoveSelected, + onClearSelected, + onOpenMobileFolders, }: { tab: DiscoveryTab; tabCounts: Record; @@ -907,6 +1001,24 @@ export function DiscoveryGrid({ onScan: () => void; scanPending: boolean; scanStatus: string | null; + folderResult?: FolderListResult | null; + folderSelection?: FolderSelection; + foldersLoading?: boolean; + selectMode?: boolean; + selectedSkillIds?: string[]; + onFolderSelect?: (selection: FolderSelection) => void; + onCreateFolder?: () => void; + onRenameFolder?: (folder: FolderListItem, name: string) => void; + onEditFolder?: (folder: FolderListItem) => void; + onDeleteFolder?: (folder: FolderListItem) => void; + onToggleSelectMode?: () => void; + onSelectCard?: (card: DiscoveryCard, selected: boolean) => void; + onMoveCard?: (card: DiscoveryCard, folderId: string | null) => void; + onCreateFolderAndMoveCard?: (card: DiscoveryCard) => void; + onMoveSelected?: (folderId: string | null) => void; + onCreateFolderAndMoveSelected?: () => void; + onClearSelected?: () => void; + onOpenMobileFolders?: () => void; }) { // Source filter (github / skills.sh / local / …) lives in the grid so it // narrows whatever the parent already filtered by tab/category/search (PAP-10907 E). @@ -926,15 +1038,33 @@ export function DiscoveryGrid({ [cards, sourceBadgeFilter], ); const sourceFilterActive = sourceBadgeFilter !== "all"; + const showFolderRail = Boolean(folderResult && folderResult.folders.length > 0 && onFolderSelect); + const folderActionsReady = Boolean(onCreateFolder && onRenameFolder && onEditFolder && onDeleteFolder); return ( // On desktop the store is bounded to the viewport so the category sidebar // and the results pane each scroll independently (PAP-10907). Mobile keeps // the natural page flow.
+ {showFolderRail && folderActionsReady ? ( +
+ +
+ ) : null} {/* Secondary category sidebar — the main app nav collapses to a rail while this is present (handled in Layout). */} -
@@ -949,7 +1140,49 @@ export function Routines() { ) : null} {activeTab === "routines" ? ( -
+
+ {showFolderRail ? ( + openCreateFolder()} + onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })} + onEdit={(folder) => { + setFolderDialogTarget(folder); + setFolderDialogOpen(true); + }} + onDelete={setDeleteFolderTarget} + /> + ) : null} +
+ {routineViewState.groupBy === "folder" && hasRoutineFolders ? ( +
+ {folderSelection === "all" ? : ( +
+ + {folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"} + {sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"} +
+ )} +
+ ) : null} + {selectMode ? ( + void moveSelectedRoutines(folderId)} + onCreateAndMove={() => openCreateFolder(selectedRoutineIds)} + onClear={() => setSelectedRoutineIds([])} + onDone={() => { + setSelectMode(false); + setSelectedRoutineIds([]); + }} + /> + ) : null} {visibleRoutines.length === 0 ? (
+ ) : sortedRoutines.length === 0 ? ( +
+ + {folderSelection !== "all" ? ( +
+ +
+ ) : null} +
) : (
{routineSections.map((group) => { @@ -995,6 +1243,32 @@ export function Routines() { onRunNow={handleRunNow} onToggleEnabled={handleToggleEnabled} onToggleArchived={handleToggleArchived} + selectMode={selectMode} + selected={selectedRoutineIds.includes(routine.id)} + onSelectChange={(selectedRoutine, selected) => { + setSelectedRoutineIds((current) => + selected + ? Array.from(new Set([...current, selectedRoutine.id])) + : current.filter((id) => id !== selectedRoutine.id) + ); + }} + extraMenuItems={ + { + moveRoutineToFolder.mutate({ itemId: routine.id, folderId }); + pushToast({ + title: "Routine moved", + body: folderId + ? `Moved to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` + : "Moved to Unfiled.", + tone: "success", + }); + }} + onCreateAndMove={() => openCreateFolder([routine.id])} + /> + } /> ))} @@ -1003,9 +1277,44 @@ export function Routines() { })}
)} +
) : null} + { + if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload }); + else createFolder.mutate(payload); + }} + /> + { + if (!open) setDeleteFolderTarget(null); + }} + onConfirm={() => { + if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id); + }} + /> + openCreateFolder()} + /> + { @@ -1027,3 +1336,13 @@ export function Routines() {
); } + +function FolderIconHeader({ label, count }: { label: string; count: number }) { + return ( +
+ + {label} + {count} routine{count === 1 ? "" : "s"} +
+ ); +}