From e048b08f9ec565fd93645e010c1705ac7146ef73 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 23 May 2026 21:13:31 +0000 Subject: [PATCH] Add projects page sorting Co-Authored-By: Paperclip --- ui/src/components/EntityRow.tsx | 11 ++- ui/src/pages/Projects.test.tsx | 164 ++++++++++++++++++++++++++++++++ ui/src/pages/Projects.tsx | 84 +++++++++++++++- 3 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 ui/src/pages/Projects.test.tsx diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index 4c375fbd070..de1c69e6048 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -12,6 +12,7 @@ interface EntityRowProps { to?: string; onClick?: () => void; className?: string; + reserveSubtitleSpace?: boolean; } export function EntityRow({ @@ -24,6 +25,7 @@ export function EntityRow({ to, onClick, className, + reserveSubtitleSpace, }: EntityRowProps) { const isClickable = !!(to || onClick); const classes = cn( @@ -45,8 +47,13 @@ export function EntityRow({ )} {title} - {subtitle && ( -

{subtitle}

+ {(subtitle || reserveSubtitleSpace) && ( +

+ {subtitle} +

)} {trailing &&
{trailing}
} diff --git a/ui/src/pages/Projects.test.tsx b/ui/src/pages/Projects.test.tsx new file mode 100644 index 00000000000..185db7663bc --- /dev/null +++ b/ui/src/pages/Projects.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Projects } from "./Projects"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); +const mockOpenNewProject = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); + +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, +})); +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => ({ openNewProject: mockOpenNewProject }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }), +})); + +function project(overrides: Partial): Project { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#14b8a6", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-1", + effectiveLocalFolder: "/tmp/project-1", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +async function renderProjects(container: HTMLElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let root: Root | null = null; + + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return root; +} + +function projectLinkNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("a[href^='/projects/']")).map((link) => { + const title = link.querySelector("span.truncate"); + return title?.textContent ?? ""; + }); +} + +describe("Projects", () => { + let root: Root | null = null; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockProjectsApi.list.mockResolvedValue([ + project({ + id: "project-bravo", + urlKey: "bravo", + name: "Bravo", + description: null, + updatedAt: new Date("2026-05-02T00:00:00Z"), + }), + project({ + id: "project-alpha", + urlKey: "alpha", + name: "Alpha", + description: "First project", + updatedAt: new Date("2026-05-01T00:00:00Z"), + }), + project({ + id: "project-charlie", + urlKey: "charlie", + name: "Charlie", + description: null, + updatedAt: new Date("2026-05-03T00:00:00Z"), + }), + ]); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("sorts projects by name by default and can switch sort mode", async () => { + root = await renderProjects(container); + + expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + + const select = container.querySelector("select"); + expect(select).not.toBeNull(); + + await act(async () => { + select!.value = "updated"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]); + }); + + it("reserves description line height for projects without descriptions", async () => { + root = await renderProjects(container); + + const bravoLink = Array.from(container.querySelectorAll("a")).find((link) => + link.textContent?.includes("Bravo"), + ); + const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']"); + + expect(hiddenDescriptionLine).not.toBeNull(); + expect(hiddenDescriptionLine?.className).toContain("min-h-4"); + }); +}); diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 08d246a8562..a26c17dd628 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -11,12 +12,67 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { formatDate, projectUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; -import { Hexagon, Plus } from "lucide-react"; +import { ArrowUpDown, Hexagon, Plus } from "lucide-react"; + +type ProjectSortMode = "name" | "updated" | "targetDate" | "status"; + +const PROJECT_SORT_LABELS: Record = { + name: "Name", + updated: "Recently updated", + targetDate: "Target date", + status: "Status", +}; + +const PROJECT_STATUS_RANK: Record = { + in_progress: 0, + planned: 1, + backlog: 2, + completed: 3, + cancelled: 4, +}; + +function projectTime(project: Project, field: "createdAt" | "updatedAt"): number { + const value = project[field]; + const time = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function compareProjectNames(left: Project, right: Project): number { + const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); + return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id); +} + +function compareTargetDates(left: Project, right: Project): number { + if (!left.targetDate && !right.targetDate) return compareProjectNames(left, right); + if (!left.targetDate) return 1; + if (!right.targetDate) return -1; + + const dateDiff = left.targetDate.localeCompare(right.targetDate); + return dateDiff !== 0 ? dateDiff : compareProjectNames(left, right); +} + +function sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] { + return [...projects].sort((left, right) => { + if (sortMode === "updated") { + const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt"); + return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right); + } + if (sortMode === "targetDate") { + return compareTargetDates(left, right); + } + if (sortMode === "status") { + const statusDiff = PROJECT_STATUS_RANK[left.status] - PROJECT_STATUS_RANK[right.status]; + return statusDiff !== 0 ? statusDiff : compareProjectNames(left, right); + } + return compareProjectNames(left, right); + }); +} export function Projects() { const { selectedCompanyId } = useCompany(); const { openNewProject } = useDialogActions(); const { setBreadcrumbs } = useBreadcrumbs(); + const [sortMode, setSortMode] = useState("name"); useEffect(() => { setBreadcrumbs([{ label: "Projects" }]); @@ -31,6 +87,10 @@ export function Projects() { () => (allProjects ?? []).filter((p) => !p.archivedAt), [allProjects], ); + const sortedProjects = useMemo( + () => sortProjects(projects, sortMode), + [projects, sortMode], + ); if (!selectedCompanyId) { return ; @@ -42,7 +102,22 @@ export function Projects() { return (
-
+
+