diff --git a/ui/src/components/SidebarProjects.test.tsx b/ui/src/components/SidebarProjects.test.tsx new file mode 100644 index 00000000000..b4d80f7ff4f --- /dev/null +++ b/ui/src/components/SidebarProjects.test.tsx @@ -0,0 +1,294 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarProjects } from "./SidebarProjects"; + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockOpenNewProject = vi.hoisted(() => vi.fn()); +const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); +const mockPersistOrder = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + {children} + ), + NavLink: ({ + children, + className, + to, + ...props + }: { + children: ReactNode; + className?: string | ((state: { isActive: boolean }) => string); + to: string; + }) => ( + + {children} + + ), + useLocation: () => ({ pathname: "/PAP/projects/bravo/issues", search: "", hash: "", state: null }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", issuePrefix: "PAP" }, + }), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => ({ + openNewProject: mockOpenNewProject, + }), + useDialogActions: () => ({ + openNewProject: mockOpenNewProject, + }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: mockSetSidebarOpen, + }), +})); + +vi.mock("../api/projects", () => ({ + projectsApi: mockProjectsApi, +})); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../hooks/useProjectOrder", () => ({ + useProjectOrder: ({ projects }: { projects: Project[] }) => { + const curatedOrder = ["project-b", "project-a", "project-c"]; + return { + orderedProjects: [...projects].sort( + (left, right) => curatedOrder.indexOf(left.id) - curatedOrder.indexOf(right.id), + ), + persistOrder: mockPersistOrder, + }; + }, +})); + +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: () => ({ + slots: [{ id: "slot-1", pluginKey: "plugin-1" }], + }), + PluginSlotMount: ({ context }: { context: { projectId: string } }) => ( +
Plugin slot
+ ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} + +function makeProject(overrides: Partial): Project { + return { + id: "project-a", + companyId: "company-1", + urlKey: "alpha", + goalId: null, + goalIds: [], + goals: [], + name: "Alpha", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#ef4444", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-a", + effectiveLocalFolder: "/tmp/project-a", + origin: "local_folder", + }, + workspaces: [], + primaryWorkspace: null, + managedByPlugin: null, + archivedAt: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function projectLinkLabels(container: HTMLElement) { + return Array.from(container.querySelectorAll('a[href$="/issues"]')) + .map((anchor) => anchor.textContent?.replace("Plugin slot", "").trim()) + .filter(Boolean); +} + +async function openProjectsMenu(container: HTMLElement) { + const trigger = container.querySelector('button[aria-label="Projects section actions"]'); + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); +} + +async function chooseSortMode(label: string) { + const item = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]')) + .find((element) => element.textContent?.includes(label)); + expect(item).toBeTruthy(); + + await act(async () => { + item?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); +} + +describe("SidebarProjects", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + localStorage.clear(); + mockProjectsApi.list.mockResolvedValue([ + makeProject({ + id: "project-a", + urlKey: "alpha", + name: "Alpha", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-05T00:00:00Z"), + }), + makeProject({ + id: "project-b", + urlKey: "bravo", + name: "Bravo", + createdAt: new Date("2026-01-02T00:00:00Z"), + updatedAt: new Date("2026-01-10T00:00:00Z"), + }), + makeProject({ + id: "project-c", + urlKey: "charlie", + name: "Charlie", + createdAt: new Date("2026-01-03T00:00:00Z"), + updatedAt: new Date("2026-01-12T00:00:00Z"), + }), + ]); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1" }, + }); + }); + + afterEach(async () => { + const currentRoot = root; + if (currentRoot) { + await act(async () => { + currentRoot.unmount(); + }); + } + queryClient.clear(); + container.remove(); + document.body.innerHTML = ""; + localStorage.clear(); + vi.clearAllMocks(); + }); + + async function renderSidebarProjects() { + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await flushReact(); + } + + it("keeps top mode in curated order and renders plugin project slots", async () => { + await renderSidebarProjects(); + + expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]); + expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy(); + }); + + it("moves project creation into the section menu and links browse projects", async () => { + await renderSidebarProjects(); + + expect(container.querySelector('button[aria-label="New project"]')).toBeNull(); + await openProjectsMenu(container); + + const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("New project")); + expect(newProjectItem).toBeTruthy(); + const browseLink = Array.from(document.body.querySelectorAll("a")) + .find((element) => element.textContent?.includes("Browse projects")); + expect(browseLink?.getAttribute("href")).toBe("/projects"); + + await act(async () => { + newProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockOpenNewProject).toHaveBeenCalledTimes(1); + }); + + it("sorts alphabetically and persists the selected mode per company and user", async () => { + await renderSidebarProjects(); + await openProjectsMenu(container); + await chooseSortMode("Alphabetical"); + + expect(projectLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + expect(localStorage.getItem("paperclip.projectSortMode:company-1:user-1")).toBe("alphabetical"); + }); + + it("sorts recent projects by updated time descending", async () => { + await renderSidebarProjects(); + await openProjectsMenu(container); + await chooseSortMode("Recent"); + + expect(projectLinkLabels(container)).toEqual(["Charlie", "Bravo", "Alpha"]); + }); +}); diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index b48bc96794b..e568f78af83 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, Plus } from "lucide-react"; +import { FolderOpen, Plus } from "lucide-react"; import { DndContext, MouseSensor, @@ -22,25 +22,31 @@ import { queryKeys } from "../lib/queryKeys"; import { cn, projectRouteRef } from "../lib/utils"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; +import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { + getProjectSortModeStorageKey, + PROJECT_SORT_MODE_UPDATED_EVENT, + readProjectSortMode, + type ProjectSidebarSortMode, + writeProjectSortMode, +} from "../lib/project-order"; import type { Project } from "@paperclipai/shared"; type ProjectSidebarSlot = ReturnType["slots"][number]; -function SortableProjectItem({ - activeProjectRef, - companyId, - companyPrefix, - isMobile, - project, - projectSidebarSlots, - setSidebarOpen, -}: { +const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [ + { value: "top", label: "Top" }, + { value: "alphabetical", label: "Alphabetical" }, + { value: "recent", label: "Recent" }, +]; + +type ProjectSortModeUpdatedDetail = { + storageKey: string; + sortMode: ProjectSidebarSortMode; +}; + +type ProjectItemProps = { activeProjectRef: string | null; companyId: string | null; companyPrefix: string | null; @@ -48,7 +54,92 @@ function SortableProjectItem({ project: Project; projectSidebarSlots: ProjectSidebarSlot[]; setSidebarOpen: (open: boolean) => void; -}) { + isDragging?: boolean; +}; + +function projectTimestamp(project: Project): number { + const updated = new Date(project.updatedAt).getTime(); + if (Number.isFinite(updated)) return updated; + const created = new Date(project.createdAt).getTime(); + return Number.isFinite(created) ? created : 0; +} + +function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Project[] { + if (sortMode === "top") return projects; + const sorted = [...projects]; + if (sortMode === "alphabetical") { + sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); + return sorted; + } + sorted.sort((left, right) => { + const timeDiff = projectTimestamp(right) - projectTimestamp(left); + return timeDiff !== 0 ? timeDiff : left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); + }); + return sorted; +} + +function ProjectItem({ + activeProjectRef, + companyId, + companyPrefix, + isMobile, + project, + projectSidebarSlots, + setSidebarOpen, + isDragging = false, +}: ProjectItemProps) { + const routeRef = projectRouteRef(project); + + return ( +
+ { + if (isDragging) { + e.preventDefault(); + return; + } + if (isMobile) setSidebarOpen(false); + }} + className={cn( + "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", + activeProjectRef === routeRef || activeProjectRef === project.id + ? "bg-accent text-foreground" + : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", + )} + > + + {project.name} + {project.pauseReason === "budget" ? : null} + + {projectSidebarSlots.length > 0 && ( +
+ {projectSidebarSlots.map((slot) => ( + + ))} +
+ )} +
+ ); +} + +function SortableProjectItem(props: ProjectItemProps) { const { attributes, listeners, @@ -56,9 +147,7 @@ function SortableProjectItem({ transform, transition, isDragging, - } = useSortable({ id: project.id }); - - const routeRef = projectRouteRef(project); + } = useSortable({ id: props.project.id }); return (
-
- { - if (isDragging) { - e.preventDefault(); - return; - } - if (isMobile) setSidebarOpen(false); - }} - className={cn( - "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", - activeProjectRef === routeRef || activeProjectRef === project.id - ? "bg-accent text-foreground" - : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", - )} - > - - {project.name} - {project.pauseReason === "budget" ? : null} - - {projectSidebarSlots.length > 0 && ( -
- {projectSidebarSlots.map((slot) => ( - - ))} -
- )} -
+
); } @@ -145,6 +190,14 @@ export function SidebarProjects() { }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const sortModeStorageKey = useMemo(() => { + if (!selectedCompanyId) return null; + return getProjectSortModeStorageKey(selectedCompanyId, currentUserId); + }, [currentUserId, selectedCompanyId]); + const [sortMode, setSortMode] = useState(() => { + if (!sortModeStorageKey) return "top"; + return readProjectSortMode(sortModeStorageKey); + }); const visibleProjects = useMemo( () => (projects ?? []).filter((project: Project) => !project.archivedAt), @@ -155,6 +208,11 @@ export function SidebarProjects() { companyId: selectedCompanyId, userId: currentUserId, }); + const sortedProjects = useMemo( + () => sortProjects(orderedProjects, sortMode), + [orderedProjects, sortMode], + ); + const isTopMode = sortMode === "top"; const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/); const activeProjectRef = projectMatch?.[1] ?? null; @@ -165,8 +223,50 @@ export function SidebarProjects() { }), ); + useEffect(() => { + if (!sortModeStorageKey) { + setSortMode("top"); + return; + } + setSortMode(readProjectSortMode(sortModeStorageKey)); + }, [sortModeStorageKey]); + + useEffect(() => { + if (!sortModeStorageKey) return; + + const onStorage = (event: StorageEvent) => { + if (event.key !== sortModeStorageKey) return; + setSortMode(readProjectSortMode(sortModeStorageKey)); + }; + const onCustomEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || detail.storageKey !== sortModeStorageKey) return; + setSortMode(detail.sortMode); + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent); + }; + }, [sortModeStorageKey]); + + const persistSortMode = useCallback( + (value: string) => { + const nextSortMode: ProjectSidebarSortMode = + value === "alphabetical" || value === "recent" ? value : "top"; + setSortMode(nextSortMode); + if (sortModeStorageKey) { + writeProjectSortMode(sortModeStorageKey, nextSortMode); + } + }, + [sortModeStorageKey], + ); + const handleDragEnd = useCallback( (event: DragEndEvent) => { + if (!isTopMode) return; const { active, over } = event; if (!over || active.id === over.id) return; @@ -177,38 +277,40 @@ export function SidebarProjects() { persistOrder(arrayMove(ids, oldIndex, newIndex)); }, - [orderedProjects, persistOrder], + [isTopMode, orderedProjects, persistOrder], + ); + + const renderProject = (project: Project) => ( + ); return ( - -
-
- - - - Projects - - - -
-
- - + + {isTopMode ? ( project.id)} strategy={verticalListSortingStrategy} > -
+
{orderedProjects.map((project: Project) => ( - - + ) : ( +
+ {sortedProjects.map((project: Project) => renderProject(project))} +
+ )} + ); } diff --git a/ui/src/components/SidebarSection.test.tsx b/ui/src/components/SidebarSection.test.tsx new file mode 100644 index 00000000000..9cce745b9f3 --- /dev/null +++ b/ui/src/components/SidebarSection.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarSection } from "./SidebarSection"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +async function openSectionMenu(container: HTMLElement) { + const trigger = container.querySelector('button[aria-label="Projects section actions"]'); + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); +} + +describe("SidebarSection", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(async () => { + const currentRoot = root; + if (currentRoot) { + await act(async () => { + currentRoot.unmount(); + }); + } + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("keeps static and collapsible section labels on the same text column", async () => { + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( +
+ + Issues + + + Projects + +
, + ); + }); + await flushReact(); + + const workLabel = Array.from(container.querySelectorAll("span")) + .find((element) => element.textContent === "Work"); + const projectsLabel = Array.from(container.querySelectorAll("span")) + .find((element) => element.textContent === "Projects"); + + expect(workLabel?.previousElementSibling?.className).toContain("w-3"); + expect(projectsLabel?.previousElementSibling?.tagName.toLowerCase()).toBe("svg"); + expect(projectsLabel?.previousElementSibling?.getAttribute("class")).toContain("w-3"); + }); + + it("renders configured menu actions and radio choices", async () => { + const onAction = vi.fn(); + const onRadioValueChange = vi.fn(); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + Projects + , + ); + }); + await flushReact(); + + await openSectionMenu(container); + + const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("New project")); + expect(newProjectItem).toBeTruthy(); + const browseLink = Array.from(document.body.querySelectorAll("a")) + .find((element) => element.textContent?.includes("Browse projects")); + expect(browseLink?.getAttribute("href")).toBe("/projects"); + + const alphabeticalItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]')) + .find((element) => element.textContent?.includes("Alphabetical")); + expect(alphabeticalItem).toBeTruthy(); + + await act(async () => { + alphabeticalItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onRadioValueChange).toHaveBeenCalledWith("alphabetical"); + + await openSectionMenu(container); + const reopenedNewProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("New project")); + + await act(async () => { + reopenedNewProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/components/SidebarSection.tsx b/ui/src/components/SidebarSection.tsx index 3dd68feb863..0eb45d5f8cc 100644 --- a/ui/src/components/SidebarSection.tsx +++ b/ui/src/components/SidebarSection.tsx @@ -1,17 +1,170 @@ -import type { ReactNode } from "react"; +import { useState, type ComponentType, type ReactNode } from "react"; +import { Link } from "@/lib/router"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +type SidebarSectionIcon = ComponentType<{ className?: string }>; + +export type SidebarSectionMenuAction = + | { + type: "item"; + label: string; + icon?: SidebarSectionIcon; + href?: string; + onSelect?: () => void; + } + | { type: "separator" }; + +export type SidebarSectionRadioChoice = { + label: string; + value: string; +}; + +type SidebarSectionMenu = { + actions?: SidebarSectionMenuAction[]; + ariaLabel?: string; + radioChoices?: SidebarSectionRadioChoice[]; + radioLabel?: string; + radioValue?: string; + onRadioValueChange?: (value: string) => void; +}; interface SidebarSectionProps { label: string; children: ReactNode; + collapsible?: { + open: boolean; + onOpenChange: (open: boolean) => void; + }; + menu?: SidebarSectionMenu; } -export function SidebarSection({ label, children }: SidebarSectionProps) { +function SidebarSectionHeader({ + collapsible, + label, + menu, +}: Pick) { + const [menuOpen, setMenuOpen] = useState(false); + const hasMenu = Boolean(menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0)); + const labelClassName = "text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60"; + const caretClassName = cn( + "h-3 w-3 shrink-0 text-muted-foreground/60 opacity-0 transition-all group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100", + collapsible?.open && "rotate-90", + menuOpen && "opacity-100", + ); + + const labelContent = ( + <> + {collapsible ? ( +