diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx index 1a1c49f2f04..3e36a498932 100644 --- a/ui/src/pages/Artifacts.test.tsx +++ b/ui/src/pages/Artifacts.test.tsx @@ -171,8 +171,8 @@ describe("Artifacts page", () => { container.remove(); }); - it("requests the flat artifact list with grouping defaults", async () => { - artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact()], nextCursor: null }); + it("requests task-grouped artifact stacks by default", async () => { + artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup()], nextCursor: null }); const { root } = renderArtifacts(container); @@ -180,11 +180,20 @@ describe("Artifacts page", () => { expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", { kind: "all", q: undefined, - groupBy: "none", + groupBy: "task", groupIssueId: undefined, limit: 30, cursor: undefined, }); + const groupControl = container.querySelector('[data-testid="artifact-group-control"]') as HTMLButtonElement; + const allFilter = [...container.querySelectorAll('[role="tab"]')] + .find((element) => element.textContent === "All") as HTMLButtonElement; + expect(groupControl).not.toBeNull(); + expect(groupControl.textContent).toBe(""); + expect(groupControl.getAttribute("data-variant")).toBe("outline"); + expect(groupControl.getAttribute("data-size")).toBe("icon"); + expect(groupControl.getAttribute("data-group-by")).toBe("task"); + expect(Boolean(groupControl.compareDocumentPosition(allFilter) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); }); flushSync(() => { @@ -194,8 +203,8 @@ describe("Artifacts page", () => { it("debounces artifact search into the artifacts API", async () => { artifactsApiMock.list - .mockResolvedValueOnce({ artifacts: [sampleArtifact()], nextCursor: null }) - .mockResolvedValueOnce({ artifacts: [], nextCursor: null }); + .mockResolvedValueOnce({ artifacts: [], groups: [sampleGroup()], nextCursor: null }) + .mockResolvedValueOnce({ artifacts: [], groups: [], nextCursor: null }); const { root } = renderArtifacts(container); @@ -217,7 +226,7 @@ describe("Artifacts page", () => { expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", { kind: "all", q: "launch", - groupBy: "none", + groupBy: "task", groupIssueId: undefined, limit: 30, cursor: undefined, @@ -232,7 +241,7 @@ describe("Artifacts page", () => { it("keeps the artifacts grid max-width constrained and left aligned", async () => { artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact()], nextCursor: null }); - const { root } = renderArtifacts(container); + const { root } = renderArtifacts(container, ["/artifacts?groupBy=none"]); await waitForAssertion(() => { expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull(); @@ -258,7 +267,7 @@ describe("Artifacts page", () => { nextCursor: null, }); - const { root } = renderArtifacts(container); + const { root } = renderArtifacts(container, ["/artifacts?groupBy=none"]); await waitForAssertion(() => { expect(container.textContent).toContain("First Artifact"); @@ -289,38 +298,39 @@ describe("Artifacts page", () => { it("switches grouping via the group control and refetches stacks", async () => { artifactsApiMock.list.mockImplementation((_companyId: string, params?: { groupBy?: string }) => { - if (params?.groupBy === "task") { - return Promise.resolve({ artifacts: [], groups: [sampleGroup()], nextCursor: null }); + if (params?.groupBy === "none") { + return Promise.resolve({ artifacts: [sampleArtifact()], nextCursor: null }); } - return Promise.resolve({ artifacts: [sampleArtifact()], nextCursor: null }); + return Promise.resolve({ artifacts: [], groups: [sampleGroup()], nextCursor: null }); }); const { root } = renderArtifacts(container); await waitForAssertion(() => { - expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="artifact-group-card"]')).not.toBeNull(); }); - const taskOption = container.querySelector( - '[data-testid="artifact-group-option-task"]', + const noneOption = container.querySelector( + '[data-testid="artifact-group-option-none"]', ) as HTMLButtonElement; - expect(taskOption).not.toBeNull(); + expect(noneOption).not.toBeNull(); flushSync(() => { - taskOption.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + noneOption.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); await waitForAssertion(() => { expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", { kind: "all", q: undefined, - groupBy: "task", + groupBy: "none", groupIssueId: undefined, limit: 30, cursor: undefined, }); - expect(container.querySelector('[data-testid="artifact-group-card"]')).not.toBeNull(); - expect(container.textContent).toContain("3 artifacts"); + expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull(); + const groupControl = container.querySelector('[data-testid="artifact-group-control"]') as HTMLElement; + expect(groupControl.getAttribute("data-group-by")).toBe("none"); }); flushSync(() => { diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx index 4f2bf97f59b..c97557fa410 100644 --- a/ui/src/pages/Artifacts.tsx +++ b/ui/src/pages/Artifacts.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; const ARTIFACTS_PAGE_SIZE = 30; const SEARCH_DEBOUNCE_MS = 250; @@ -46,7 +47,8 @@ const GROUP_OPTIONS: { value: ArtifactGroupBy; label: string }[] = [ const KIND_VALUES = new Set(KIND_FILTERS.map((filter) => filter.value)); function parseGroupBy(value: string | null): ArtifactGroupBy { - return value === "task" || value === "parent_task" ? value : "none"; + if (value === "none" || value === "task" || value === "parent_task") return value; + return "task"; } function parseKind(value: string | null): ArtifactKindFilter { @@ -127,7 +129,7 @@ export function Artifacts() { updateParams((next) => { // Switching the grouping mode always returns to the stack list. next.delete("groupIssueId"); - if (value === "none") next.delete("groupBy"); + if (value === "task") next.delete("groupBy"); else next.set("groupBy", value); }); }, @@ -263,6 +265,38 @@ export function Artifacts() {
+ + + + + + Group by + {GROUP_OPTIONS.map((option) => ( + selectGroupBy(option.value)} + className="justify-between" + > + {option.label} + {groupBy === option.value ? : null} + + ))} + + +
{KIND_FILTERS.map((filter) => (
- - - - - - - Group by - {GROUP_OPTIONS.map((option) => ( - selectGroupBy(option.value)} - className="justify-between" - > - {option.label} - {groupBy === option.value ? : null} - - ))} - -
diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/artifacts.stories.tsx index 1336b36fbe9..79b27024acb 100644 --- a/ui/storybook/stories/artifacts.stories.tsx +++ b/ui/storybook/stories/artifacts.stories.tsx @@ -1,22 +1,43 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Package } from "lucide-react"; +import { useState } from "react"; +import { Check, ChevronLeft, Layers, Package, Search, X } from "lucide-react"; import { ArtifactCard } from "@/components/artifacts/ArtifactCard"; import { EmptyState } from "@/components/EmptyState"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn, formatDate } from "@/lib/utils"; import type { CompanyArtifact } from "@/api/artifacts"; /** - * Storybook coverage for the company Artifacts page (PAP-10359). Renders the - * responsive three-column grid and every preview card variant with mock data so - * UX/QA can review the layout and capture desktop/mobile screenshots without a - * live backend. + * Storybook coverage for the company Artifacts page. Covers: + * - the flat grid (PAP-10359) + * - the new group-by control, stack cards, and selected stack view (PAP-10440 / PAP-10442) + * + * Each story is renderable standalone so UX/QA can capture desktop and mobile + * screenshots without booting a live backend. */ +type GroupBy = "none" | "task" | "parent_task"; + const SAMPLE_IMAGE = "data:image/svg+xml;utf8," + encodeURIComponent( `Hero render.png`, ); +const SAMPLE_IMAGE_TEAL = + "data:image/svg+xml;utf8," + + encodeURIComponent( + `nav-revised.png`, + ); + +const SAMPLE_IMAGE_AMBER = + "data:image/svg+xml;utf8," + + encodeURIComponent( + `hero-warm.png`, + ); + function makeArtifact(overrides: Partial): CompanyArtifact { return { id: "art", @@ -44,7 +65,7 @@ const ARTIFACTS: CompanyArtifact[] = [ mediaKind: "video", title: "Product demo — primary cut.mp4", contentType: "video/mp4", - contentPath: null, // exercises the calm video placeholder + play glyph + contentPath: null, openPath: "/files/demo.mp4", downloadPath: "/files/demo.mp4?download=1", issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" }, @@ -96,7 +117,7 @@ const ARTIFACTS: CompanyArtifact[] = [ mediaKind: "image", title: "missing-preview.png (broken source)", contentType: "image/png", - contentPath: "/files/does-not-exist.png", // exercises the onError image fallback + contentPath: "/files/does-not-exist.png", openPath: "/files/does-not-exist.png", downloadPath: "/files/does-not-exist.png?download=1", }), @@ -104,7 +125,7 @@ const ARTIFACTS: CompanyArtifact[] = [ function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) { return ( -
+
{artifacts.map((artifact) => ( ))} @@ -112,6 +133,357 @@ function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) { ); } +// --------------------------------------------------------------------------- +// Grouping mock components (PAP-10442). These mirror the production artifact +// grouping controls so Storybook stays useful for visual review. +// --------------------------------------------------------------------------- + +const KIND_FILTERS = [ + { value: "all", label: "All" }, + { value: "image", label: "Images" }, + { value: "video", label: "Videos" }, + { value: "document", label: "Documents" }, + { value: "text", label: "Text" }, + { value: "file", label: "Files" }, +] as const; + +const GROUP_OPTIONS: { value: GroupBy; label: string }[] = [ + { value: "none", label: "None" }, + { value: "task", label: "Task" }, + { value: "parent_task", label: "Parent task" }, +]; + +/** + * Toolbar replica matching the existing Artifacts page (search + kind filters) + * with the group-by icon control placed before the filter chips. + */ +function ArtifactsToolbar({ + query, + onQueryChange, + kind, + onKindChange, + groupBy, + onGroupByChange, +}: { + query: string; + onQueryChange: (value: string) => void; + kind: (typeof KIND_FILTERS)[number]["value"]; + onKindChange: (value: (typeof KIND_FILTERS)[number]["value"]) => void; + groupBy: GroupBy; + onGroupByChange: (value: GroupBy) => void; +}) { + return ( +
+
+ + onQueryChange(event.currentTarget.value)} + placeholder="Search artifacts..." + aria-label="Search artifacts" + className="h-9 pl-9 pr-9 text-sm" + /> + {query.length > 0 ? ( + + ) : null} +
+ +
+ + + + + +
+ Group by +
+
+ {GROUP_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+
+ +
+ {KIND_FILTERS.map((filter) => ( + + ))} +
+
+
+ ); +} + +interface MockGroup { + id: string; + groupBy: Exclude; + issueIdentifier: string; + issueTitle: string; + count: number; + preview: CompanyArtifact; + updatedAt: string; + href: string; +} + +/** + * Stack card mock for grouped views. + * + * Visual rules (matching ArtifactCard footprint): + * - Same border, radius (rounded-[8px]), border + bg-card surface, hover treatment. + * - Preview frame is the same `aspect-video` PreviewFrame as ArtifactCard. + * - Footer block has the same vertical rhythm. + * - When `count > 1`, render two stacked sibling layers behind the main card, + * offset by 4px / 8px on both axes, with reduced opacity. This produces a + * subtle "stack" without competing with the preview content. + * - Stack count badge sits in the top-right of the preview as a small pill. + * - The body line replaces the artifact title with the issue identifier and + * title; metadata line shows the artifact count and most-recent-edit time. + */ +function ArtifactStackCard({ group }: { group: MockGroup }) { + const isStacked = group.count > 1; + + return ( +
+ {isStacked ? ( + <> +