From 04c7907ff5eef02ba81a74ec27250252cf64e3f8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 13:47:53 +0000 Subject: [PATCH] feat(artifacts): grouped /artifacts UI with URL state (PAP-10444) Add a group-by control (None / Task / Parent task) to the company Artifacts page and drive grouping, selected stack, media filter, and search entirely from URL params so every view is shareable and reloadable. - ui/src/api/artifacts.ts: serialize groupBy/groupIssueId; preserve groups/selectedGroup in the normalized envelope. - ui/src/lib/queryKeys.ts: key artifact queries by groupBy/groupIssueId. - ui/src/components/artifacts/ArtifactGroupCard.tsx: new stack card with count metadata, count-only badge, and a stack effect when count > 1. - ui/src/components/artifacts/ArtifactCard.tsx: export ArtifactPreview so group cards reuse identical previews. - ui/src/pages/Artifacts.tsx: URL-backed kind/q/groupBy/groupIssueId, group control, stack-list and selected-stack rendering, back affordance, breadcrumb awareness, filter/search preservation. - Tests: query serialization, group-control navigation, stack rendering, selected-stack restore, filter preservation, and ArtifactGroupCard stack/count/link coverage. Builds on the shared/server grouping contract (PAP-10443). Co-Authored-By: Paperclip --- ui/src/api/artifacts.test.ts | 35 +++ ui/src/api/artifacts.ts | 20 +- ui/src/components/artifacts/ArtifactCard.tsx | 2 +- .../artifacts/ArtifactGroupCard.test.tsx | 121 +++++++ .../artifacts/ArtifactGroupCard.tsx | 87 +++++ ui/src/lib/queryKeys.ts | 17 +- ui/src/pages/Artifacts.test.tsx | 198 +++++++++++- ui/src/pages/Artifacts.tsx | 296 +++++++++++++++--- 8 files changed, 719 insertions(+), 57 deletions(-) create mode 100644 ui/src/components/artifacts/ArtifactGroupCard.test.tsx create mode 100644 ui/src/components/artifacts/ArtifactGroupCard.tsx diff --git a/ui/src/api/artifacts.test.ts b/ui/src/api/artifacts.test.ts index fffb68b96ae..89a41bb41cf 100644 --- a/ui/src/api/artifacts.test.ts +++ b/ui/src/api/artifacts.test.ts @@ -59,6 +59,41 @@ describe("artifactsApi.list", () => { ); }); + it("omits groupBy when grouping is none", async () => { + await artifactsApi.list("company-1", { groupBy: "none" }); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("serializes groupBy and the selected stack issue", async () => { + await artifactsApi.list("company-1", { + groupBy: "parent_task", + groupIssueId: "issue-9", + kind: "image", + }); + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/artifacts?kind=image&groupBy=parent_task&groupIssueId=issue-9", + ); + }); + + it("preserves groups and selectedGroup from the envelope", async () => { + const artifact = sampleArtifact(); + const group = { + id: "task:issue-1", + groupBy: "task" as const, + issue: artifact.issue, + title: "Demo reel", + count: 3, + mediaKinds: ["video" as const], + previewArtifacts: [artifact], + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-1", + }; + mockApi.get.mockResolvedValue({ artifacts: [], groups: [group], nextCursor: "next" }); + const result = await artifactsApi.list("company-1", { groupBy: "task" }); + expect(result.groups).toEqual([group]); + expect(result.nextCursor).toBe("next"); + }); + it("returns the envelope shape from the backend", async () => { const artifact = sampleArtifact(); mockApi.get.mockResolvedValue({ artifacts: [artifact], nextCursor: "next" }); diff --git a/ui/src/api/artifacts.ts b/ui/src/api/artifacts.ts index 78508305a9a..e0653245d38 100644 --- a/ui/src/api/artifacts.ts +++ b/ui/src/api/artifacts.ts @@ -1,12 +1,15 @@ import { api } from "./client"; import type { CompanyArtifact, + CompanyArtifactGroupBy, CompanyArtifactMediaKind, CompanyArtifactsResponse, } from "@paperclipai/shared"; export type { CompanyArtifact, + CompanyArtifactGroup, + CompanyArtifactGroupBy as ArtifactGroupBy, CompanyArtifactMediaKind as ArtifactMediaKind, CompanyArtifactsResponse, CompanyArtifactSource as ArtifactSource, @@ -32,6 +35,10 @@ export interface ListArtifactsParams { kind?: ArtifactKindFilter; projectId?: string; q?: string; + /** Grouping mode. `none` (default) returns the flat artifact grid. */ + groupBy?: CompanyArtifactGroupBy; + /** When grouping, selects a single stack to expand into its artifacts. */ + groupIssueId?: string; limit?: number; cursor?: string; } @@ -41,6 +48,8 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string { if (params?.kind && params.kind !== "all") search.set("kind", params.kind); if (params?.projectId) search.set("projectId", params.projectId); if (params?.q) search.set("q", params.q); + if (params?.groupBy && params.groupBy !== "none") search.set("groupBy", params.groupBy); + if (params?.groupIssueId) search.set("groupIssueId", params.groupIssueId); if (params?.limit != null) search.set("limit", String(params.limit)); if (params?.cursor) search.set("cursor", params.cursor); const qs = search.toString(); @@ -49,8 +58,8 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string { /** * Normalize the endpoint response. The contract is an envelope - * (`{ artifacts, nextCursor }`), but we also tolerate a bare array so the page - * keeps working if the backend ships the simpler shape. + * (`{ artifacts, groups?, selectedGroup?, nextCursor }`), but we also tolerate a + * bare array so the page keeps working if the backend ships the simpler shape. */ function normalizeArtifactsResponse( raw: CompanyArtifactsResponse | CompanyArtifact[], @@ -58,7 +67,12 @@ function normalizeArtifactsResponse( if (Array.isArray(raw)) { return { artifacts: raw, nextCursor: null }; } - return { artifacts: raw.artifacts ?? [], nextCursor: raw.nextCursor ?? null }; + return { + artifacts: raw.artifacts ?? [], + groups: raw.groups, + selectedGroup: raw.selectedGroup, + nextCursor: raw.nextCursor ?? null, + }; } export const artifactsApi = { diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index 842c0ee6651..f5f6d0e70ef 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -97,7 +97,7 @@ function TextPreview({ artifact }: { artifact: CompanyArtifact }) { ); } -function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { +export function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { switch (artifact.mediaKind) { case "image": return ; diff --git a/ui/src/components/artifacts/ArtifactGroupCard.test.tsx b/ui/src/components/artifacts/ArtifactGroupCard.test.tsx new file mode 100644 index 00000000000..aff9f5dc5c2 --- /dev/null +++ b/ui/src/components/artifacts/ArtifactGroupCard.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtifactGroupCard } from "./ArtifactGroupCard"; +import type { CompanyArtifact, CompanyArtifactGroup } from "@/api/artifacts"; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompany: null, selectedCompanyId: "company-1" }), +})); + +function sampleArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "artifact-1", + source: "image", + mediaKind: "image", + title: "Hero shot", + previewText: null, + contentType: "image/png", + contentPath: "/files/hero.png", + openPath: "/files/hero.png", + downloadPath: "/files/hero.png?download=1", + issue: { id: "issue-1", identifier: "PAP-42", title: "Ship launch" }, + project: null, + createdByAgent: null, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/issues/PAP-42#attachment-1", + ...overrides, + } as CompanyArtifact; +} + +function sampleGroup(overrides: Partial = {}): CompanyArtifactGroup { + return { + id: "task:issue-1", + groupBy: "task", + issue: { id: "issue-1", identifier: "PAP-42", title: "Ship launch" }, + title: "Ship launch", + count: 3, + mediaKinds: ["image"], + previewArtifacts: [sampleArtifact()], + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-1", + ...overrides, + }; +} + +function render(group: CompanyArtifactGroup, to = "?groupBy=task&groupIssueId=issue-1") { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + flushSync(() => { + root.render( + + + , + ); + }); + return { container, root }; +} + +describe("ArtifactGroupCard", () => { + let mounted: { container: HTMLElement; root: ReturnType } | null = null; + + beforeEach(() => { + mounted = null; + }); + + afterEach(() => { + if (mounted) { + flushSync(() => mounted!.root.unmount()); + mounted.container.remove(); + mounted = null; + } + }); + + it("shows a stack effect and plural count when count > 1", () => { + mounted = render(sampleGroup({ count: 3 })); + const card = mounted.container.querySelector('[data-testid="artifact-group-card"]') as HTMLElement; + expect(card).not.toBeNull(); + expect(card.getAttribute("data-stacked")).toBe("true"); + expect(card.getAttribute("data-count")).toBe("3"); + // Two decorative stack layers sit behind the card. + expect(mounted.container.querySelectorAll('[data-testid="artifact-stack-layer"]').length).toBe(2); + expect(mounted.container.textContent).toContain("3 artifacts"); + }); + + it("omits the stack effect and uses singular count when count === 1", () => { + mounted = render(sampleGroup({ count: 1 })); + const card = mounted.container.querySelector('[data-testid="artifact-group-card"]') as HTMLElement; + expect(card.getAttribute("data-stacked")).toBe("false"); + expect(card.getAttribute("data-count")).toBe("1"); + expect(mounted.container.querySelectorAll('[data-testid="artifact-stack-layer"]').length).toBe(0); + expect(mounted.container.textContent).toContain("1 artifact"); + expect(mounted.container.textContent).not.toContain("1 artifacts"); + }); + + it("links to the provided stack destination and shows the task subject", () => { + mounted = render(sampleGroup()); + const anchor = mounted.container.querySelector("a") as HTMLAnchorElement; + expect(anchor).not.toBeNull(); + expect(anchor.getAttribute("href")).toContain("groupIssueId=issue-1"); + expect(mounted.container.textContent).toContain("PAP-42"); + expect(mounted.container.textContent).toContain("Ship launch"); + }); + + it("renders the first preview artifact image", () => { + mounted = render(sampleGroup()); + const img = mounted.container.querySelector("img") as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.getAttribute("src")).toBe("/files/hero.png"); + }); + + it("falls back to a placeholder when there are no preview artifacts", () => { + mounted = render(sampleGroup({ previewArtifacts: [] })); + expect(mounted.container.querySelector("img")).toBeNull(); + const card = mounted.container.querySelector('[data-testid="artifact-group-card"]') as HTMLElement; + expect(card).not.toBeNull(); + }); +}); diff --git a/ui/src/components/artifacts/ArtifactGroupCard.tsx b/ui/src/components/artifacts/ArtifactGroupCard.tsx new file mode 100644 index 00000000000..68544f402c1 --- /dev/null +++ b/ui/src/components/artifacts/ArtifactGroupCard.tsx @@ -0,0 +1,87 @@ +import { Layers } from "lucide-react"; +import type { To } from "react-router-dom"; +import type { CompanyArtifactGroup } from "@/api/artifacts"; +import { Link } from "@/lib/router"; +import { ArtifactPreview } from "@/components/artifacts/ArtifactCard"; +import { formatDate } from "@/lib/utils"; + +interface ArtifactGroupCardProps { + group: CompanyArtifactGroup; + /** Destination for opening this stack (preserves active filters/search). */ + to: To; +} + +/** + * A stack card rendered in grouped mode. It mirrors the dimensions and preview + * of {@link ArtifactCard} so grouped and flat grids share the same rhythm, and + * layers a subtle "stack" effect behind the card only when it represents more + * than one artifact. + */ +export function ArtifactGroupCard({ group, to }: ArtifactGroupCardProps) { + const stacked = group.count > 1; + const preview = group.previewArtifacts[0]; + const countLabel = `${group.count} artifact${group.count === 1 ? "" : "s"}`; + + return ( +
+ {stacked ? ( + <> +