mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Add folder UI for routines and skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a072fafbb8
commit
b309aa0fc5
10 files changed
+1597
-40
No files matched your search
@@ -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<typeof import("../middleware/index.js")>,
|
||||
import("../routes/folders.js") as Promise<typeof import("../routes/folders.js")>,
|
||||
]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FolderListResult>(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`),
|
||||
create: (companyId: string, payload: CreateFolderRequest) =>
|
||||
api.post<Folder>(`/companies/${encodeURIComponent(companyId)}/folders`, payload),
|
||||
update: (companyId: string, folderId: string, payload: UpdateFolderRequest) =>
|
||||
api.patch<Folder>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
|
||||
payload,
|
||||
),
|
||||
moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) =>
|
||||
api.post<Folder>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`,
|
||||
payload,
|
||||
),
|
||||
moveItem: (companyId: string, payload: MoveFolderItemRequest) =>
|
||||
api.post<MoveFolderItemRequest>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/items/move`,
|
||||
payload,
|
||||
),
|
||||
delete: (companyId: string, folderId: string) =>
|
||||
api.delete<{ deleted: Folder }>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
|
||||
),
|
||||
};
|
||||
@@ -64,6 +64,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
disableToggle = false,
|
||||
hideArchiveAction = false,
|
||||
divider = true,
|
||||
selected = false,
|
||||
selectMode = false,
|
||||
extraMenuItems,
|
||||
onSelectChange,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
@@ -83,6 +87,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
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<TRoutine extends RoutineListRowItem>({
|
||||
divider ? " border-b border-border last:border-b-0" : ""
|
||||
}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
<div
|
||||
className="flex items-start pt-0.5 sm:pt-1"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-border"
|
||||
checked={selected}
|
||||
aria-label={`Select ${routine.title}`}
|
||||
onChange={(event) => onSelectChange?.(routine, event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||
@@ -178,6 +203,12 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
>
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</DropdownMenuItem>
|
||||
{extraMenuItems ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{extraMenuItems}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleEnabled(routine, enabled)}
|
||||
|
||||
@@ -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<void>) {
|
||||
let result: void | Promise<void> | 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(
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection="all"
|
||||
itemLabelPlural="routines"
|
||||
allLabel="All routines"
|
||||
onSelect={onSelect}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("h-2.5 w-2.5 shrink-0 rounded-[3px] border border-border/40", className)}
|
||||
style={{ backgroundColor: color || "#64748b" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function selectionLabel({
|
||||
folders,
|
||||
selection,
|
||||
allLabel,
|
||||
}: {
|
||||
folders: FolderListItem[];
|
||||
selection: FolderSelection;
|
||||
allLabel: string;
|
||||
}) {
|
||||
if (selection === "all") return allLabel;
|
||||
if (selection === "unfiled") return "Unfiled";
|
||||
return folders.find((folder) => folder.id === selection)?.name ?? allLabel;
|
||||
}
|
||||
|
||||
function selectionCount(result: FolderListResult | null | undefined, selection: FolderSelection) {
|
||||
if (!result) return 0;
|
||||
if (selection === "all") return result.allCount;
|
||||
if (selection === "unfiled") return result.unfiledCount;
|
||||
return result.folders.find((folder) => folder.id === selection)?.itemCount ?? 0;
|
||||
}
|
||||
|
||||
export function FolderChip({
|
||||
result,
|
||||
selection,
|
||||
allLabel,
|
||||
onClick,
|
||||
}: {
|
||||
result: FolderListResult | null | undefined;
|
||||
selection: FolderSelection;
|
||||
allLabel: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const folder = result ? selectedFolderFromList(result.folders, selection) : null;
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="max-w-full justify-start" onClick={onClick}>
|
||||
{selection === "all" ? <FolderIcon className="mr-2 h-3.5 w-3.5" /> : <FolderSwatch color={folder?.color} className="mr-2" />}
|
||||
<span className="truncate">{selectionLabel({ folders: result?.folders ?? [], selection, allLabel })}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{selectionCount(result, selection)}</span>
|
||||
<ChevronDown className="ml-1 h-3.5 w-3.5 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderRail({
|
||||
result,
|
||||
selection,
|
||||
itemLabelPlural,
|
||||
allLabel,
|
||||
loading = false,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRename,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
result: FolderListResult | null | undefined;
|
||||
selection: FolderSelection;
|
||||
itemLabelPlural: string;
|
||||
allLabel: string;
|
||||
loading?: boolean;
|
||||
onSelect: (selection: FolderSelection) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (folder: FolderListItem, name: string) => void;
|
||||
onEdit: (folder: FolderListItem) => void;
|
||||
onDelete: (folder: FolderListItem) => void;
|
||||
}) {
|
||||
const folders = result?.folders ?? [];
|
||||
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingFolderId) return;
|
||||
const folder = folders.find((entry) => entry.id === renamingFolderId);
|
||||
if (!folder) setRenamingFolderId(null);
|
||||
}, [folders, renamingFolderId]);
|
||||
|
||||
function startRename(folder: FolderListItem) {
|
||||
setRenamingFolderId(folder.id);
|
||||
setRenameDraft(folder.name);
|
||||
}
|
||||
|
||||
function commitRename(folder: FolderListItem) {
|
||||
const name = renameDraft.trim();
|
||||
if (name && name !== folder.name) onRename(folder, name);
|
||||
setRenamingFolderId(null);
|
||||
}
|
||||
|
||||
function renderVirtualRow(
|
||||
key: FolderSelection,
|
||||
label: string,
|
||||
count: number,
|
||||
icon: ReactNode,
|
||||
) {
|
||||
const active = selection === key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/40",
|
||||
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<span className="h-4 w-4">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={`${itemLabelPlural} folders`} className="hidden w-[13.25rem] shrink-0 border-r border-border pr-3 md:block">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Folders</div>
|
||||
<Button variant="ghost" size="icon-sm" title="New folder" onClick={onCreate}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 rounded-md bg-muted/60" />
|
||||
<div className="h-7 rounded-md bg-muted/40" />
|
||||
<div className="h-7 rounded-md bg-muted/30" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{renderVirtualRow("all", allLabel, result?.allCount ?? 0, <FolderIcon className="h-3.5 w-3.5" />)}
|
||||
{folders.map((folder) => {
|
||||
const active = selection === folder.id;
|
||||
const isRenaming = renamingFolderId === folder.id;
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className={cn(
|
||||
"group grid grid-cols-[1rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent/40",
|
||||
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="h-4 w-4" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 text-left"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelect(folder.id)}
|
||||
onDoubleClick={() => startRename(folder)}
|
||||
>
|
||||
<FolderSwatch color={folder.color} />
|
||||
{isRenaming ? (
|
||||
<input
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRename(folder);
|
||||
if (event.key === "Escape") setRenamingFolderId(null);
|
||||
}}
|
||||
onBlur={() => commitRename(folder)}
|
||||
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{folder.name}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{folder.itemCount}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={`Folder actions for ${folder.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => startRename(folder)}>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onEdit(folder)}>Edit color</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(folder)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="px-2 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
System
|
||||
</div>
|
||||
{renderVirtualRow("unfiled", "Unfiled", result?.unfiledCount ?? 0, <FolderSwatch color={null} className="mt-0.5" />)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileFolderSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
result,
|
||||
selection,
|
||||
allLabel,
|
||||
itemLabelPlural,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
result: FolderListResult | null | undefined;
|
||||
selection: FolderSelection;
|
||||
allLabel: string;
|
||||
itemLabelPlural: string;
|
||||
onSelect: (selection: FolderSelection) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
function select(next: FolderSelection) {
|
||||
onSelect(next);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const folders = result?.folders ?? [];
|
||||
const rows = [
|
||||
{ id: "all" as FolderSelection, label: allLabel, count: result?.allCount ?? 0, color: null },
|
||||
...folders.map((folder) => ({ id: folder.id, label: folder.name, count: folder.itemCount, color: folder.color })),
|
||||
{ id: "unfiled" as FolderSelection, label: "Unfiled", count: result?.unfiledCount ?? 0, color: null },
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="max-h-[80dvh] rounded-t-lg pb-4">
|
||||
<SheetHeader className="border-b border-border px-4 py-3">
|
||||
<SheetTitle>{itemLabelPlural} folders</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="overflow-y-auto px-3">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent/50"
|
||||
onClick={() => select(row.id)}
|
||||
>
|
||||
{row.id === "all" ? <FolderIcon className="h-3.5 w-3.5 text-muted-foreground" /> : <FolderSwatch color={row.color} />}
|
||||
<span className="min-w-0 flex-1 truncate">{row.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{row.count}</span>
|
||||
{selection === row.id ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-4 pt-3">
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={onCreate}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
New folder
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoveToMenu({
|
||||
folders,
|
||||
currentFolderId,
|
||||
onMove,
|
||||
onCreateAndMove,
|
||||
}: {
|
||||
folders: FolderListItem[];
|
||||
currentFolderId: string | null | undefined;
|
||||
onMove: (folderId: string | null) => void;
|
||||
onCreateAndMove: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const visibleFolders = useMemo(() => {
|
||||
const lowered = query.trim().toLowerCase();
|
||||
if (!lowered) return folders;
|
||||
return folders.filter((folder) => folder.name.toLowerCase().includes(lowered));
|
||||
}, [folders, query]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Move to...</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder="Search folders"
|
||||
className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onMove(null)}>
|
||||
<FolderSwatch color={null} />
|
||||
Unfiled
|
||||
{currentFolderId == null ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
{visibleFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onSelect={() => onMove(folder.id)}>
|
||||
<FolderSwatch color={folder.color} />
|
||||
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{visibleFolders.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">No folders match.</div>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onCreateAndMove}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New folder...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderFormDialog({
|
||||
open,
|
||||
kind,
|
||||
folder,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
pending = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
kind: FolderKind;
|
||||
folder: FolderListItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: { name: string; color: string | null }) => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState<string | null>(FOLDER_COLORS[0] ?? null);
|
||||
const isEdit = Boolean(folder);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(folder?.name ?? "");
|
||||
setColor(folder?.color ?? FOLDER_COLORS[0] ?? null);
|
||||
}, [folder, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit folder" : "Create folder"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kind === "routine" ? "Organize routines in this company." : "Organize installed company skills."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="folder-name">Name</label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && name.trim()) onSubmit({ name: name.trim(), color });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Color</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FOLDER_COLORS.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
aria-label={`Use folder color ${swatch}`}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md border border-border",
|
||||
color === swatch && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
style={{ backgroundColor: swatch }}
|
||||
onClick={() => setColor(swatch)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 rounded-md border border-border px-2 text-xs text-muted-foreground",
|
||||
color == null && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
onClick={() => setColor(null)}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onSubmit({ name: name.trim(), color })} disabled={pending || !name.trim()}>
|
||||
{pending ? "Saving..." : isEdit ? "Save" : "Create folder"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteFolderDialog({
|
||||
open,
|
||||
folder,
|
||||
itemLabelPlural,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
folder: FolderListItem | null;
|
||||
itemLabelPlural: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete folder</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The {folder?.itemCount ?? 0} {itemLabelPlural} in this folder won't be deleted. They'll move to Unfiled.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={pending || !folder}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{pending ? "Deleting..." : "Delete folder"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulkBar({
|
||||
selectedCount,
|
||||
folders,
|
||||
onMove,
|
||||
onCreateAndMove,
|
||||
onClear,
|
||||
onDone,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
folders: FolderListItem[];
|
||||
onMove: (folderId: string | null) => void;
|
||||
onCreateAndMove: () => void;
|
||||
onClear: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
if (selectedCount === 0) return null;
|
||||
return (
|
||||
<div className="sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/95 px-3 py-2 shadow-sm backdrop-blur">
|
||||
<span className="mr-auto text-sm text-muted-foreground">{selectedCount} selected</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline">Move to...</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<MoveToMenu
|
||||
folders={folders}
|
||||
currentFolderId={undefined}
|
||||
onMove={onMove}
|
||||
onCreateAndMove={onCreateAndMove}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size="sm" variant="ghost" onClick={onClear}>Deselect all</Button>
|
||||
<Button size="sm" onClick={onDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -193,6 +193,9 @@ export const queryKeys = {
|
||||
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
|
||||
["routines", "document-annotations", routineId, key, status] as const,
|
||||
},
|
||||
folders: {
|
||||
list: (companyId: string, kind: string) => ["folders", companyId, kind] as const,
|
||||
},
|
||||
pipelines: {
|
||||
list: (companyId: string) => ["pipelines", companyId] as const,
|
||||
detail: (pipelineId: string) => ["pipelines", "detail", pipelineId] as const,
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CompanySkillVersion,
|
||||
} from "@paperclipai/shared";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { foldersApi } from "../api/folders";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -89,6 +90,18 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
BulkBar,
|
||||
DeleteFolderDialog,
|
||||
FolderChip,
|
||||
FolderFormDialog,
|
||||
FolderRail,
|
||||
MobileFolderSheet,
|
||||
MoveToMenu,
|
||||
folderSearchValue,
|
||||
normalizeFolderSelection,
|
||||
type FolderSelection,
|
||||
} from "../components/folders/FolderControls";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
@@ -114,6 +127,7 @@ import {
|
||||
Lock,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Pencil,
|
||||
@@ -131,6 +145,7 @@ import {
|
||||
History,
|
||||
XOctagon,
|
||||
} from "lucide-react";
|
||||
import type { FolderListItem, FolderListResult } from "@paperclipai/shared";
|
||||
|
||||
type SkillTreeNode = {
|
||||
name: string;
|
||||
@@ -537,6 +552,7 @@ const DISCOVERY_SORTS: DiscoverySort[] = ["agents", "stars", "forks", "recent",
|
||||
export type DiscoveryCard = {
|
||||
key: string;
|
||||
skillId: string | null;
|
||||
folderId?: string | null;
|
||||
catalogRef: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -610,6 +626,7 @@ function buildDiscoveryCards(
|
||||
cards.push({
|
||||
key: skill.key,
|
||||
skillId: skill.id,
|
||||
folderId: skill.folderId ?? null,
|
||||
catalogRef: catalogMatch ? catalogMatch.id : null,
|
||||
name: skill.name,
|
||||
slug: skill.slug,
|
||||
@@ -638,6 +655,7 @@ function buildDiscoveryCards(
|
||||
cards.push({
|
||||
key: entry.key,
|
||||
skillId: null,
|
||||
folderId: null,
|
||||
catalogRef: entry.id,
|
||||
name: entry.name,
|
||||
slug: entry.slug,
|
||||
@@ -730,11 +748,36 @@ function SkillCategoryChip({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: DiscoveryCard) => void }) {
|
||||
function SkillCard({
|
||||
card,
|
||||
folders,
|
||||
selected = false,
|
||||
selectMode = false,
|
||||
onOpen,
|
||||
onSelectChange,
|
||||
onMove,
|
||||
onCreateFolderAndMove,
|
||||
}: {
|
||||
card: DiscoveryCard;
|
||||
folders?: FolderListItem[];
|
||||
selected?: boolean;
|
||||
selectMode?: boolean;
|
||||
onOpen: (card: DiscoveryCard) => void;
|
||||
onSelectChange?: (card: DiscoveryCard, selected: boolean) => void;
|
||||
onMove?: (card: DiscoveryCard, folderId: string | null) => void;
|
||||
onCreateFolderAndMove?: (card: DiscoveryCard) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
onClick={() => onOpen(card)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(card);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
// Quiet interactive-card affordance (DECISION-SHEET: one recipe for
|
||||
// clickable cards): pointer cursor, border darkens, slight lift.
|
||||
@@ -743,6 +786,16 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{selectMode && card.installed ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-border"
|
||||
checked={selected}
|
||||
aria-label={`Select ${card.name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => onSelectChange?.(card, event.target.checked)}
|
||||
/>
|
||||
) : null}
|
||||
<SkillCardIcon card={card} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-sm font-medium text-foreground">{card.name}</div>
|
||||
@@ -760,6 +813,29 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{card.installed && folders && onMove && onCreateFolderAndMove ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="-mr-1 -mt-1 opacity-70 group-hover:opacity-100"
|
||||
aria-label={`More actions for ${card.name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||
<MoveToMenu
|
||||
folders={folders}
|
||||
currentFolderId={card.folderId}
|
||||
onMove={(folderId) => onMove(card, folderId)}
|
||||
onCreateAndMove={() => onCreateFolderAndMove(card)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{card.forkedFrom ? (
|
||||
@@ -813,7 +889,7 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<DiscoveryTab, number>;
|
||||
@@ -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.
|
||||
<div className="flex min-h-(--sz-calc-30) md:h-(--sz-calc-33) md:min-h-0 md:overflow-hidden">
|
||||
{showFolderRail && folderActionsReady ? (
|
||||
<div className="hidden shrink-0 pl-4 pt-4 md:block">
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection={folderSelection}
|
||||
itemLabelPlural="skills"
|
||||
allLabel="All skills"
|
||||
loading={foldersLoading}
|
||||
onSelect={onFolderSelect!}
|
||||
onCreate={onCreateFolder!}
|
||||
onRename={onRenameFolder!}
|
||||
onEdit={onEditFolder!}
|
||||
onDelete={onDeleteFolder!}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Secondary category sidebar — the main app nav collapses to a rail while
|
||||
this is present (handled in Layout). */}
|
||||
<aside className="hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex">
|
||||
<aside className={cn("hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex", showFolderRail && "md:hidden")}>
|
||||
<div className="border-b border-border px-4 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Skills Store</h2>
|
||||
<p className="text-xs text-muted-foreground">Discover, install, fork, share</p>
|
||||
@@ -1043,6 +1173,27 @@ export function DiscoveryGrid({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{folderResult && onFolderSelect ? (
|
||||
<div className="w-full md:hidden">
|
||||
<FolderChip
|
||||
result={folderResult}
|
||||
selection={folderSelection}
|
||||
allLabel="All skills"
|
||||
onClick={onOpenMobileFolders ?? (() => undefined)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onCreateFolder ? (
|
||||
<Button variant="outline" size="sm" onClick={onCreateFolder}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
New folder
|
||||
</Button>
|
||||
) : null}
|
||||
{onToggleSelectMode ? (
|
||||
<Button variant="ghost" size="sm" onClick={onToggleSelectMode}>
|
||||
{selectMode ? "Done" : "Select"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mobile category selector (sidebar is hidden below md) */}
|
||||
@@ -1099,6 +1250,18 @@ export function DiscoveryGrid({
|
||||
{/* Grid body */}
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
|
||||
{selectMode && onMoveSelected && onCreateFolderAndMoveSelected && onClearSelected ? (
|
||||
<div className="mb-3">
|
||||
<BulkBar
|
||||
selectedCount={selectedSkillIds.length}
|
||||
folders={folderResult?.folders ?? []}
|
||||
onMove={onMoveSelected}
|
||||
onCreateAndMove={onCreateFolderAndMoveSelected}
|
||||
onClear={onClearSelected}
|
||||
onDone={onToggleSelectMode ?? onClearSelected}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : error ? (
|
||||
@@ -1148,7 +1311,17 @@ export function DiscoveryGrid({
|
||||
</p>
|
||||
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(19rem,1fr))]">
|
||||
{sourceFilteredCards.map((card) => (
|
||||
<SkillCard key={card.key} card={card} onOpen={onOpenCard} />
|
||||
<SkillCard
|
||||
key={card.key}
|
||||
card={card}
|
||||
folders={folderResult?.folders}
|
||||
selected={selectedSkillIds.includes(card.skillId ?? "")}
|
||||
selectMode={selectMode}
|
||||
onOpen={onOpenCard}
|
||||
onSelectChange={onSelectCard}
|
||||
onMove={onMoveCard}
|
||||
onCreateFolderAndMove={onCreateFolderAndMoveCard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -3541,6 +3714,13 @@ export function CompanySkills() {
|
||||
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
|
||||
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
|
||||
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
||||
const [moveAfterCreateSkillIds, setMoveAfterCreateSkillIds] = useState<string[]>([]);
|
||||
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
|
||||
const isStudioNew = routePath === "studio/new";
|
||||
const routeSkillToken = isStudioNew ? null : parsedRoute.skillToken;
|
||||
@@ -3566,6 +3746,7 @@ export function CompanySkills() {
|
||||
// Discovery grid owns `/skills` whenever no specific skill or catalog entry is
|
||||
// selected; selecting either drops into the existing master/detail surfaces.
|
||||
const isDiscovery = !isStudioNew && !routeSkillToken && !selectedCatalogRef;
|
||||
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
|
||||
|
||||
function setDiscoveryTab(tab: DiscoveryTab) {
|
||||
setSearchParams((current) => {
|
||||
@@ -3573,10 +3754,29 @@ export function CompanySkills() {
|
||||
if (tab === "all") params.delete("tab");
|
||||
else params.set("tab", tab);
|
||||
params.delete("category");
|
||||
if (tab !== "installed") params.delete("folder");
|
||||
return params;
|
||||
});
|
||||
}
|
||||
|
||||
function setFolderSelection(selection: FolderSelection) {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
params.set("tab", "installed");
|
||||
params.delete("category");
|
||||
const value = folderSearchValue(selection);
|
||||
if (value) params.set("folder", value);
|
||||
else params.delete("folder");
|
||||
return params;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateFolder(moveSkillIds: string[] = []) {
|
||||
setMoveAfterCreateSkillIds(moveSkillIds);
|
||||
setFolderDialogTarget(null);
|
||||
setFolderDialogOpen(true);
|
||||
}
|
||||
|
||||
function setDetailTab(tab: SkillDetailTab) {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
@@ -3646,6 +3846,11 @@ export function CompanySkills() {
|
||||
queryFn: () => companySkillsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const skillFoldersQuery = useQuery({
|
||||
queryKey: queryKeys.folders.list(selectedCompanyId ?? "", "skill"),
|
||||
queryFn: () => foldersApi.list(selectedCompanyId!, "skill"),
|
||||
enabled: Boolean(selectedCompanyId && isDiscovery && discoveryTab === "installed"),
|
||||
});
|
||||
|
||||
const installedSkills = skillsQuery.data ?? [];
|
||||
const routeResolution = useMemo(
|
||||
@@ -4025,10 +4230,14 @@ export function CompanySkills() {
|
||||
const visibleDiscoveryCards = useMemo(() => {
|
||||
const filtered = discoveryTabCards.filter((card) => {
|
||||
if (discoveryCategory && !card.categories.includes(discoveryCategory)) return false;
|
||||
if (discoveryTab === "installed") {
|
||||
if (folderSelection === "unfiled" && card.folderId) return false;
|
||||
if (folderSelection !== "all" && folderSelection !== "unfiled" && card.folderId !== folderSelection) return false;
|
||||
}
|
||||
return discoveryMatchesSearch(card, discoverySearch.trim());
|
||||
});
|
||||
return sortDiscoveryCards(filtered, discoverySort, discoveryTab !== "bundled");
|
||||
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab]);
|
||||
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab, folderSelection]);
|
||||
|
||||
const selectedCatalogSkill = catalogDetailQuery.data
|
||||
?? (catalogListQuery.data ?? []).find((entry) => entry.id === selectedCatalogRef || entry.key === selectedCatalogRef)
|
||||
@@ -4096,6 +4305,87 @@ export function CompanySkills() {
|
||||
policyDenial.capture(error, "Installing this skill");
|
||||
},
|
||||
});
|
||||
const createFolder = useMutation({
|
||||
mutationFn: (payload: { name: string; color: string | null }) =>
|
||||
foldersApi.create(selectedCompanyId!, { kind: "skill", ...payload }),
|
||||
onSuccess: async (folder) => {
|
||||
setFolderDialogOpen(false);
|
||||
setFolderDialogTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") });
|
||||
if (moveAfterCreateSkillIds.length > 0) {
|
||||
const ids = moveAfterCreateSkillIds;
|
||||
setMoveAfterCreateSkillIds([]);
|
||||
await Promise.all(ids.map((itemId) =>
|
||||
foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId: folder.id })
|
||||
));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
|
||||
]);
|
||||
} else {
|
||||
setFolderSelection(folder.id);
|
||||
}
|
||||
pushToast({ tone: "success", title: "Folder created", body: folder.name });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Folder save failed",
|
||||
body: error instanceof Error ? error.message : "Failed to save folder.",
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateFolder = useMutation({
|
||||
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
|
||||
foldersApi.update(selectedCompanyId!, folderId, payload),
|
||||
onSuccess: async () => {
|
||||
setFolderDialogOpen(false);
|
||||
setFolderDialogTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") });
|
||||
},
|
||||
});
|
||||
const deleteFolder = useMutation({
|
||||
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
|
||||
onSuccess: async (_, folderId) => {
|
||||
if (folderSelection === folderId) setFolderSelection("all");
|
||||
setDeleteFolderTarget(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
|
||||
]);
|
||||
pushToast({ tone: "success", title: "Folder deleted", body: "Skills moved to Unfiled." });
|
||||
},
|
||||
});
|
||||
const moveSkillToFolder = useMutation({
|
||||
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
|
||||
foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Move failed",
|
||||
body: error instanceof Error ? error.message : "Failed to move skill.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function moveSelectedSkills(folderId: string | null) {
|
||||
const ids = selectedSkillIds;
|
||||
if (ids.length === 0) return;
|
||||
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId })));
|
||||
setSelectedSkillIds([]);
|
||||
setSelectMode(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
|
||||
]);
|
||||
pushToast({ tone: "success", title: "Skills moved", body: `${ids.length} skill${ids.length === 1 ? "" : "s"} filed.` });
|
||||
}
|
||||
|
||||
const eligibleAgentsForAttach = useMemo(() => {
|
||||
const data = agentsQuery.data ?? [];
|
||||
@@ -4263,6 +4553,8 @@ export function CompanySkills() {
|
||||
const studioDescription = studioForkFromId
|
||||
? "Review the fork metadata and create an editable company copy."
|
||||
: "Create an editable company skill in the Paperclip workspace.";
|
||||
const skillFolderResult = skillFoldersQuery.data ?? null;
|
||||
const showInstalledFolders = isDiscovery && discoveryTab === "installed";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -4431,6 +4723,40 @@ export function CompanySkills() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<FolderFormDialog
|
||||
open={folderDialogOpen}
|
||||
kind="skill"
|
||||
folder={folderDialogTarget}
|
||||
pending={createFolder.isPending || updateFolder.isPending}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onSubmit={(payload) => {
|
||||
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
|
||||
else createFolder.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
<DeleteFolderDialog
|
||||
open={deleteFolderTarget !== null}
|
||||
folder={deleteFolderTarget}
|
||||
itemLabelPlural="skills"
|
||||
pending={deleteFolder.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteFolderTarget(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
|
||||
}}
|
||||
/>
|
||||
<MobileFolderSheet
|
||||
open={mobileFoldersOpen}
|
||||
onOpenChange={setMobileFoldersOpen}
|
||||
result={skillFolderResult}
|
||||
selection={folderSelection}
|
||||
allLabel="All skills"
|
||||
itemLabelPlural="Skills"
|
||||
onSelect={setFolderSelection}
|
||||
onCreate={() => openCreateFolder()}
|
||||
/>
|
||||
|
||||
{isStudioNew ? (
|
||||
<div className="min-h-(--sz-calc-30)">
|
||||
<div className="border-b border-border px-4 py-5">
|
||||
@@ -4486,6 +4812,49 @@ export function CompanySkills() {
|
||||
onScan={() => scanProjects.mutate()}
|
||||
scanPending={scanProjects.isPending}
|
||||
scanStatus={scanStatusMessage}
|
||||
folderResult={showInstalledFolders ? skillFolderResult : null}
|
||||
folderSelection={folderSelection}
|
||||
foldersLoading={skillFoldersQuery.isLoading}
|
||||
selectMode={showInstalledFolders && selectMode}
|
||||
selectedSkillIds={selectedSkillIds}
|
||||
onFolderSelect={showInstalledFolders ? setFolderSelection : undefined}
|
||||
onOpenMobileFolders={showInstalledFolders ? () => setMobileFoldersOpen(true) : undefined}
|
||||
onCreateFolder={showInstalledFolders ? () => openCreateFolder() : undefined}
|
||||
onRenameFolder={showInstalledFolders ? (folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } }) : undefined}
|
||||
onEditFolder={showInstalledFolders ? (folder) => {
|
||||
setFolderDialogTarget(folder);
|
||||
setFolderDialogOpen(true);
|
||||
} : undefined}
|
||||
onDeleteFolder={showInstalledFolders ? setDeleteFolderTarget : undefined}
|
||||
onToggleSelectMode={showInstalledFolders ? () => {
|
||||
setSelectMode((current) => !current);
|
||||
if (selectMode) setSelectedSkillIds([]);
|
||||
} : undefined}
|
||||
onSelectCard={showInstalledFolders ? (card, selected) => {
|
||||
if (!card.skillId) return;
|
||||
setSelectedSkillIds((current) =>
|
||||
selected
|
||||
? Array.from(new Set([...current, card.skillId!]))
|
||||
: current.filter((id) => id !== card.skillId)
|
||||
);
|
||||
} : undefined}
|
||||
onMoveCard={showInstalledFolders ? (card, folderId) => {
|
||||
if (!card.skillId) return;
|
||||
moveSkillToFolder.mutate({ itemId: card.skillId, folderId });
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Skill moved",
|
||||
body: folderId
|
||||
? `Moved to ${skillFolderResult?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
|
||||
: "Moved to Unfiled.",
|
||||
});
|
||||
} : undefined}
|
||||
onCreateFolderAndMoveCard={showInstalledFolders ? (card) => {
|
||||
if (card.skillId) openCreateFolder([card.skillId]);
|
||||
} : undefined}
|
||||
onMoveSelected={showInstalledFolders ? (folderId) => void moveSelectedSkills(folderId) : undefined}
|
||||
onCreateFolderAndMoveSelected={showInstalledFolders ? () => openCreateFolder(selectedSkillIds) : undefined}
|
||||
onClearSelected={showInstalledFolders ? () => setSelectedSkillIds([]) : undefined}
|
||||
/>
|
||||
) : activeView === "installed" && selectedSkillId ? (
|
||||
<SkillDetailPage
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AnchorHTMLAttributes, ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import type { FolderListResult, Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
|
||||
|
||||
@@ -20,6 +20,7 @@ async function act(callback: () => void | Promise<void>) {
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
|
||||
const foldersListMock = vi.fn<(companyId: string, kind: string) => Promise<FolderListResult>>();
|
||||
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
||||
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
|
||||
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
||||
@@ -58,6 +59,16 @@ vi.mock("../api/routines", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/folders", () => ({
|
||||
foldersApi: {
|
||||
list: (companyId: string, kind: string) => foldersListMock(companyId, kind),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
moveItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
|
||||
@@ -343,6 +354,13 @@ describe("Routines page", () => {
|
||||
currentSearch = "";
|
||||
navigateMock.mockReset();
|
||||
routinesListMock.mockReset();
|
||||
foldersListMock.mockReset();
|
||||
foldersListMock.mockResolvedValue({
|
||||
kind: "routine",
|
||||
folders: [],
|
||||
allCount: 0,
|
||||
unfiledCount: 0,
|
||||
});
|
||||
issuesListMock.mockReset();
|
||||
markdownEditorRenderMock.mockClear();
|
||||
issuesListRenderMock.mockClear();
|
||||
@@ -398,6 +416,22 @@ describe("Routines page", () => {
|
||||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
|
||||
});
|
||||
|
||||
it("uses a flat group when Folder grouping is active", () => {
|
||||
const routines = [
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2" }),
|
||||
];
|
||||
|
||||
const groups = buildRoutineGroups(
|
||||
routines,
|
||||
"folder",
|
||||
new Map(),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(groups).toEqual([{ key: "__all", label: null, items: routines }]);
|
||||
});
|
||||
|
||||
it("sorts routines by selected field and direction without mutating the source list", () => {
|
||||
const routines = [
|
||||
createRoutine({
|
||||
@@ -489,7 +523,7 @@ describe("Routines page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the routines list to project groups sorted by title", async () => {
|
||||
it("defaults the routines list to folder mode without rendering project groups", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Weekly digest", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
||||
@@ -513,17 +547,15 @@ describe("Routines page", () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Project Alpha"); attempts += 1) {
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
expect(text.indexOf("Weekly digest")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
expect(text).toContain("New folder");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
+327
-8
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { foldersApi } from "../api/folders";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -12,6 +13,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
@@ -46,6 +48,21 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
||||
import type { FolderListItem } from "@paperclipai/shared";
|
||||
import {
|
||||
BulkBar,
|
||||
DeleteFolderDialog,
|
||||
FolderChip,
|
||||
FolderFormDialog,
|
||||
FolderRail,
|
||||
FolderSwatch,
|
||||
MobileFolderSheet,
|
||||
MoveToMenu,
|
||||
folderSearchValue,
|
||||
normalizeFolderSelection,
|
||||
selectedFolderFromList,
|
||||
type FolderSelection,
|
||||
} from "../components/folders/FolderControls";
|
||||
|
||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||
@@ -66,7 +83,7 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
|
||||
}
|
||||
|
||||
type RoutinesTab = "routines" | "runs";
|
||||
type RoutineGroupBy = "none" | "project" | "assignee";
|
||||
type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
|
||||
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
|
||||
type RoutineSortDir = "asc" | "desc";
|
||||
|
||||
@@ -88,7 +105,7 @@ const builtInRoutineGroupKey = "__built_in_routines";
|
||||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "title",
|
||||
sortDir: "asc",
|
||||
groupBy: "project",
|
||||
groupBy: "folder",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
@@ -120,6 +137,7 @@ function buildRoutineMutationPayload(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
projectId: string;
|
||||
folderId: string | null;
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
@@ -130,6 +148,7 @@ function buildRoutineMutationPayload(input: {
|
||||
...input,
|
||||
description: input.description.trim() || null,
|
||||
projectId: input.projectId || null,
|
||||
folderId: input.folderId || null,
|
||||
assigneeAgentId: input.assigneeAgentId || null,
|
||||
};
|
||||
}
|
||||
@@ -140,7 +159,7 @@ export function buildRoutineGroups(
|
||||
projectById: Map<string, { name: string }>,
|
||||
agentById: Map<string, { name: string }>,
|
||||
): RoutineGroup[] {
|
||||
if (groupByValue === "none") {
|
||||
if (groupByValue === "none" || groupByValue === "folder") {
|
||||
return [{ key: "__all", label: null, items: routines }];
|
||||
}
|
||||
|
||||
@@ -267,12 +286,19 @@ export function Routines() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pushToast } = useToastActions();
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
|
||||
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
|
||||
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedRoutineIds, setSelectedRoutineIds] = useState<string[]>([]);
|
||||
const [moveAfterCreateIds, setMoveAfterCreateIds] = useState<string[]>([]);
|
||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
|
||||
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||
@@ -283,6 +309,7 @@ export function Routines() {
|
||||
title: string;
|
||||
description: string;
|
||||
projectId: string;
|
||||
folderId: string | null;
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
@@ -292,6 +319,7 @@ export function Routines() {
|
||||
title: "",
|
||||
description: "",
|
||||
projectId: "",
|
||||
folderId: null,
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
@@ -302,6 +330,7 @@ export function Routines() {
|
||||
? `paperclip:routines-view:${selectedCompanyId}`
|
||||
: "paperclip:routines-view";
|
||||
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
|
||||
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Routines" }]);
|
||||
@@ -316,6 +345,11 @@ export function Routines() {
|
||||
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: routineFolders, isLoading: foldersLoading } = useQuery({
|
||||
queryKey: queryKeys.folders.list(selectedCompanyId!, "routine"),
|
||||
queryFn: () => foldersApi.list(selectedCompanyId!, "routine"),
|
||||
enabled: !!selectedCompanyId && activeTab === "routines",
|
||||
});
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
@@ -373,6 +407,7 @@ export function Routines() {
|
||||
title: "",
|
||||
description: "",
|
||||
projectId: "",
|
||||
folderId: null,
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
@@ -392,6 +427,74 @@ export function Routines() {
|
||||
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||
},
|
||||
});
|
||||
const createFolder = useMutation({
|
||||
mutationFn: (payload: { name: string; color: string | null }) =>
|
||||
foldersApi.create(selectedCompanyId!, { kind: "routine", ...payload }),
|
||||
onSuccess: async (folder) => {
|
||||
setFolderDialogOpen(false);
|
||||
setFolderDialogTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
|
||||
if (moveAfterCreateIds.length > 0) {
|
||||
const ids = moveAfterCreateIds;
|
||||
setMoveAfterCreateIds([]);
|
||||
await Promise.all(ids.map((itemId) =>
|
||||
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId: folder.id })
|
||||
));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
} else {
|
||||
setFolderSelection(folder.id);
|
||||
}
|
||||
pushToast({ title: "Folder created", body: folder.name, tone: "success" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
pushToast({
|
||||
title: "Failed to save folder",
|
||||
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the folder.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateFolder = useMutation({
|
||||
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
|
||||
foldersApi.update(selectedCompanyId!, folderId, payload),
|
||||
onSuccess: async () => {
|
||||
setFolderDialogOpen(false);
|
||||
setFolderDialogTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
|
||||
},
|
||||
});
|
||||
const deleteFolder = useMutation({
|
||||
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
|
||||
onSuccess: async (_, folderId) => {
|
||||
if (folderSelection === folderId) setFolderSelection("all");
|
||||
setDeleteFolderTarget(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
pushToast({ title: "Folder deleted", body: "Items moved to Unfiled.", tone: "success" });
|
||||
},
|
||||
});
|
||||
const moveRoutineToFolder = useMutation({
|
||||
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
|
||||
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
pushToast({
|
||||
title: "Move failed",
|
||||
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not move the routine.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
@@ -494,9 +597,15 @@ export function Routines() {
|
||||
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
|
||||
[routines],
|
||||
);
|
||||
const folderFilteredRoutines = useMemo(() => {
|
||||
if (routineViewState.groupBy !== "folder") return visibleRoutines;
|
||||
if (folderSelection === "all") return visibleRoutines;
|
||||
if (folderSelection === "unfiled") return visibleRoutines.filter((routine) => !routine.folderId);
|
||||
return visibleRoutines.filter((routine) => routine.folderId === folderSelection);
|
||||
}, [folderSelection, routineViewState.groupBy, visibleRoutines]);
|
||||
const sortedRoutines = useMemo(
|
||||
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
|
||||
() => sortRoutines(folderFilteredRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField],
|
||||
);
|
||||
const routineSections = useMemo(
|
||||
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
@@ -513,6 +622,9 @@ export function Routines() {
|
||||
);
|
||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||
const activeFolder = selectedFolderFromList(routineFolders?.folders ?? [], folderSelection);
|
||||
const hasRoutineFolders = (routineFolders?.folders.length ?? 0) > 0;
|
||||
const showFolderRail = activeTab === "routines" && routineViewState.groupBy === "folder" && hasRoutineFolders;
|
||||
|
||||
function updateRoutineView(patch: Partial<RoutineViewState>) {
|
||||
setRoutineViewState((current) => {
|
||||
@@ -529,6 +641,43 @@ export function Routines() {
|
||||
});
|
||||
}
|
||||
|
||||
function setFolderSelection(selection: FolderSelection) {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
const value = folderSearchValue(selection);
|
||||
if (value) params.set("folder", value);
|
||||
else params.delete("folder");
|
||||
return params;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateFolder(moveItemIds: string[] = []) {
|
||||
setMoveAfterCreateIds(moveItemIds);
|
||||
setFolderDialogTarget(null);
|
||||
setFolderDialogOpen(true);
|
||||
}
|
||||
|
||||
function openCreateRoutine() {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
folderId: folderSelection === "all" || folderSelection === "unfiled" ? null : folderSelection,
|
||||
}));
|
||||
setComposerOpen(true);
|
||||
}
|
||||
|
||||
async function moveSelectedRoutines(folderId: string | null) {
|
||||
const ids = selectedRoutineIds;
|
||||
if (ids.length === 0) return;
|
||||
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId })));
|
||||
setSelectedRoutineIds([]);
|
||||
setSelectMode(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
pushToast({ title: "Routines moved", body: `${ids.length} routine${ids.length === 1 ? "" : "s"} filed.`, tone: "success" });
|
||||
}
|
||||
|
||||
function handleRunNow(routine: RoutineListItem) {
|
||||
setRunDialogRoutine(routine);
|
||||
}
|
||||
@@ -574,7 +723,7 @@ export function Routines() {
|
||||
Recurring work definitions that materialize into auditable execution tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setComposerOpen(true)}>
|
||||
<Button onClick={openCreateRoutine}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create routine
|
||||
</Button>
|
||||
@@ -647,6 +796,7 @@ export function Routines() {
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["folder", "Folder"],
|
||||
["project", "Project"],
|
||||
["assignee", "Agent"],
|
||||
["none", "None"],
|
||||
@@ -667,8 +817,29 @@ export function Routines() {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{routineViewState.groupBy === "folder" && !hasRoutineFolders ? (
|
||||
<Button variant="outline" size="sm" onClick={() => openCreateFolder()}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
New folder
|
||||
</Button>
|
||||
) : null}
|
||||
{showFolderRail ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setSelectMode((current) => !current)}>
|
||||
{selectMode ? "Done" : "Select"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{routineViewState.groupBy === "folder" ? (
|
||||
<div className="md:hidden">
|
||||
<FolderChip
|
||||
result={routineFolders}
|
||||
selection={folderSelection}
|
||||
allLabel="All routines"
|
||||
onClick={() => setMobileFoldersOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="runs">
|
||||
<IssuesList
|
||||
@@ -843,6 +1014,26 @@ export function Routines() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span>filed in</span>
|
||||
<Select
|
||||
value={draft.folderId ?? "__unfiled"}
|
||||
onValueChange={(value) => setDraft((current) => ({
|
||||
...current,
|
||||
folderId: value === "__unfiled" ? null : value,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-32 border-0 bg-muted/50 px-2 shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unfiled">Unfiled</SelectItem>
|
||||
{(routineFolders?.folders ?? []).map((folder) => (
|
||||
<SelectItem key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,7 +1140,49 @@ export function Routines() {
|
||||
) : null}
|
||||
|
||||
{activeTab === "routines" ? (
|
||||
<div>
|
||||
<div className={cn(showFolderRail && "flex gap-4")}>
|
||||
{showFolderRail ? (
|
||||
<FolderRail
|
||||
result={routineFolders}
|
||||
selection={folderSelection}
|
||||
allLabel="All routines"
|
||||
itemLabelPlural="routines"
|
||||
loading={foldersLoading}
|
||||
onSelect={setFolderSelection}
|
||||
onCreate={() => openCreateFolder()}
|
||||
onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })}
|
||||
onEdit={(folder) => {
|
||||
setFolderDialogTarget(folder);
|
||||
setFolderDialogOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteFolderTarget}
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
{routineViewState.groupBy === "folder" && hasRoutineFolders ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{folderSelection === "all" ? <FolderIconHeader label="All routines" count={sortedRoutines.length} /> : (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<FolderSwatch color={activeFolder?.color} />
|
||||
<span className="truncate font-medium">{folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"}</span>
|
||||
<span className="text-muted-foreground">{sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{selectMode ? (
|
||||
<BulkBar
|
||||
selectedCount={selectedRoutineIds.length}
|
||||
folders={routineFolders?.folders ?? []}
|
||||
onMove={(folderId) => void moveSelectedRoutines(folderId)}
|
||||
onCreateAndMove={() => openCreateFolder(selectedRoutineIds)}
|
||||
onClear={() => setSelectedRoutineIds([])}
|
||||
onDone={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedRoutineIds([]);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{visibleRoutines.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
@@ -957,6 +1190,21 @@ export function Routines() {
|
||||
message="No active routines. Use Create routine to define the first recurring workflow."
|
||||
/>
|
||||
</div>
|
||||
) : sortedRoutines.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
message={folderSelection === "all" ? "No routines match this view." : "This folder is empty."}
|
||||
/>
|
||||
{folderSelection !== "all" ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button size="sm" onClick={openCreateRoutine}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
New routine in this folder
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{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={
|
||||
<MoveToMenu
|
||||
folders={routineFolders?.folders ?? []}
|
||||
currentFolderId={routine.folderId ?? null}
|
||||
onMove={(folderId) => {
|
||||
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])}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -1003,9 +1277,44 @@ export function Routines() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FolderFormDialog
|
||||
open={folderDialogOpen}
|
||||
kind="routine"
|
||||
folder={folderDialogTarget}
|
||||
pending={createFolder.isPending || updateFolder.isPending}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onSubmit={(payload) => {
|
||||
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
|
||||
else createFolder.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
<DeleteFolderDialog
|
||||
open={deleteFolderTarget !== null}
|
||||
folder={deleteFolderTarget}
|
||||
itemLabelPlural="routines"
|
||||
pending={deleteFolder.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteFolderTarget(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
|
||||
}}
|
||||
/>
|
||||
<MobileFolderSheet
|
||||
open={mobileFoldersOpen}
|
||||
onOpenChange={setMobileFoldersOpen}
|
||||
result={routineFolders}
|
||||
selection={folderSelection}
|
||||
allLabel="All routines"
|
||||
itemLabelPlural="Routines"
|
||||
onSelect={setFolderSelection}
|
||||
onCreate={() => openCreateFolder()}
|
||||
/>
|
||||
|
||||
<RoutineRunVariablesDialog
|
||||
open={runDialogRoutine !== null}
|
||||
onOpenChange={(next) => {
|
||||
@@ -1027,3 +1336,13 @@ export function Routines() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIconHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Repeat className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{label}</span>
|
||||
<span className="text-muted-foreground">{count} routine{count === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user