From 48bff053ff0b6ccb08917766014245b363233cdb Mon Sep 17 00:00:00 2001 From: pitoi Date: Thu, 21 May 2026 19:22:34 +0000 Subject: [PATCH] Generated with Hive: Add admin-only inline run history accordion to Workflow Marketplace cards --- src/components/layout/workflows-panel.tsx | 173 +++++++++++++- src/components/modals/janitor-settings.tsx | 22 +- src/components/ui/run-status-badge.tsx | 24 ++ src/lib/__tests__/workflows-panel.test.tsx | 264 ++++++++++++++++++++- src/lib/graph-api.ts | 1 + src/lib/mock-data.ts | 2 + 6 files changed, 453 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/run-status-badge.tsx diff --git a/src/components/layout/workflows-panel.tsx b/src/components/layout/workflows-panel.tsx index 1895bd0..44edff2 100644 --- a/src/components/layout/workflows-panel.tsx +++ b/src/components/layout/workflows-panel.tsx @@ -1,10 +1,13 @@ "use client" -import { useEffect, useState } from "react" -import { Cpu, Loader2, X } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { ChevronDown, ChevronUp, Cpu, ExternalLink, Loader2, X } from "lucide-react" import { ScrollArea } from "@/components/ui/scroll-area" -import { getWorkflowMarketplace, type WorkflowMarketplaceItem, type CronKind } from "@/lib/graph-api" -import { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE } from "@/lib/mock-data" +import { RunStatusBadge } from "@/components/ui/run-status-badge" +import { getWorkflowMarketplace, getCronRuns, type WorkflowMarketplaceItem, type CronKind, type StakworkRun } from "@/lib/graph-api" +import { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE, MOCK_STAKWORK_RUNS } from "@/lib/mock-data" +import { formatDateRelative } from "@/lib/date-format" +import { useUserStore } from "@/stores/user-store" import { cn } from "@/lib/utils" import { WORKFLOW_TYPE_META, @@ -30,14 +33,124 @@ function kindBadgeClass(kind: CronKind): string { : "bg-violet-500/15 text-violet-400 border border-violet-500/25" } -function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) { +function WorkflowRunRow({ run }: { run: StakworkRun }) { + const timestamp = run.finished_at ?? run.started_at ?? run.created_at + return ( +
+
+ + + {formatDateRelative(timestamp, "Never")} + + {run.project_id && ( + + + + )} +
+ {run.error && ( +

{run.error}

+ )} +
+ ) +} + +function WorkflowRunsSection({ + item, + runsCache, +}: { + item: WorkflowMarketplaceItem + runsCache: React.MutableRefObject> +}) { + const [runs, setRuns] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (runsCache.current[item.source_type]) { + setRuns(runsCache.current[item.source_type]) + return + } + let cancelled = false + setLoading(true) + async function fetchRuns() { + try { + if (isMocksEnabled()) { + const filtered = MOCK_STAKWORK_RUNS.filter( + (r) => r.source_type === item.source_type + ).slice(0, 10) + if (!cancelled) { + runsCache.current[item.source_type] = filtered + setRuns(filtered) + } + } else { + const { runs: fetched } = await getCronRuns({ + source_type: item.source_type, + kind: item.kind, + limit: 10, + }) + if (!cancelled) { + runsCache.current[item.source_type] = fetched + setRuns(fetched) + } + } + } catch { + /* silent */ + } finally { + if (!cancelled) setLoading(false) + } + } + fetchRuns() + return () => { + cancelled = true + } + }, [item.source_type, item.kind, runsCache]) + + return ( +
+ {loading ? ( +
+ +
+ ) : !runs || runs.length === 0 ? ( +

No runs yet

+ ) : ( +
+ {runs.map((run) => ( + + ))} +
+ )} +
+ ) +} + +function WorkflowCard({ + item, + isAdmin, + isExpanded, + onToggle, + runsCache, +}: { + item: WorkflowMarketplaceItem + isAdmin?: boolean + isExpanded?: boolean + onToggle?: () => void + runsCache?: React.MutableRefObject> +}) { const meta = WORKFLOW_TYPE_META[item.source_type] const Icon = meta?.icon ?? Cpu const tone = meta?.tone ?? "slate" const displayName = getWorkflowDisplayName(item) - return ( -
+ const cardHeader = ( + <> {/* Tinted icon tile */}
- {/* Name — single source of truth, no duplicate sub-text */} + {/* Name */}

{displayName}

- {/* Kind chip + enabled dot */} + {/* Kind chip + enabled dot + optional chevron */}
+ {isAdmin && ( + isExpanded + ? + : + )}
+ + ) + + if (isAdmin) { + return ( +
+ + {isExpanded && runsCache && ( + + )} +
+ ) + } + + return ( +
+ {cardHeader}
) } @@ -82,6 +223,9 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) { const [workflows, setWorkflows] = useState([]) const [loading, setLoading] = useState(true) const [filter, setFilter] = useState("all") + const [expandedId, setExpandedId] = useState(null) + const runsCache = useRef>({}) + const isAdmin = useUserStore((s) => s.isAdmin) useEffect(() => { let cancelled = false @@ -167,7 +311,16 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) { ) : (
{filtered.map((item) => ( - + + setExpandedId((prev) => (prev === item.ref_id ? null : item.ref_id)) + } + runsCache={runsCache} + /> ))}
)} diff --git a/src/components/modals/janitor-settings.tsx b/src/components/modals/janitor-settings.tsx index 1d1bf03..7254d8c 100644 --- a/src/components/modals/janitor-settings.tsx +++ b/src/components/modals/janitor-settings.tsx @@ -15,27 +15,7 @@ import { updateCronConfig, } from "@/lib/graph-api" import { isMocksEnabled, MOCK_CRON_CONFIGS, MOCK_STAKWORK_RUNS } from "@/lib/mock-data" - -function RunStatusBadge({ status }: { status: StakworkRun["status"] }) { - const colours: Record = { - completed: "bg-green-500/15 text-green-400", - error: "bg-destructive/15 text-destructive", - in_progress: "bg-blue-500/15 text-blue-400", - pending: "bg-yellow-500/15 text-yellow-400", - halted: "bg-orange-500/15 text-orange-400", - PENDING: "bg-yellow-500/15 text-yellow-400", - RUNNING: "bg-blue-500/15 text-blue-400", - COMPLETED: "bg-green-500/15 text-green-400", - FAILED: "bg-destructive/15 text-destructive", - ERROR: "bg-destructive/15 text-destructive", - HALTED: "bg-orange-500/15 text-orange-400", - } - return ( - - {status} - - ) -} +import { RunStatusBadge } from "@/components/ui/run-status-badge" export function JanitorSettings({ open }: { open: boolean }) { const [configs, setConfigs] = useState(null) diff --git a/src/components/ui/run-status-badge.tsx b/src/components/ui/run-status-badge.tsx new file mode 100644 index 0000000..be16140 --- /dev/null +++ b/src/components/ui/run-status-badge.tsx @@ -0,0 +1,24 @@ +"use client" + +import type { StakworkRun } from "@/lib/graph-api" + +export function RunStatusBadge({ status }: { status: StakworkRun["status"] }) { + const colours: Record = { + completed: "bg-green-500/15 text-green-400", + error: "bg-destructive/15 text-destructive", + in_progress: "bg-blue-500/15 text-blue-400", + pending: "bg-yellow-500/15 text-yellow-400", + halted: "bg-orange-500/15 text-orange-400", + PENDING: "bg-yellow-500/15 text-yellow-400", + RUNNING: "bg-blue-500/15 text-blue-400", + COMPLETED: "bg-green-500/15 text-green-400", + FAILED: "bg-destructive/15 text-destructive", + ERROR: "bg-destructive/15 text-destructive", + HALTED: "bg-orange-500/15 text-orange-400", + } + return ( + + {status} + + ) +} diff --git a/src/lib/__tests__/workflows-panel.test.tsx b/src/lib/__tests__/workflows-panel.test.tsx index 3fe52be..5cb2c28 100644 --- a/src/lib/__tests__/workflows-panel.test.tsx +++ b/src/lib/__tests__/workflows-panel.test.tsx @@ -1,11 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import type { WorkflowMarketplaceItem } from "@/lib/graph-api" +import type { WorkflowMarketplaceItem, StakworkRun } from "@/lib/graph-api" // Mock modules before importing the component vi.mock("@/lib/graph-api", () => ({ getWorkflowMarketplace: vi.fn(), + getCronRuns: vi.fn(), +})) + +const userState = { isAdmin: false } +vi.mock("@/stores/user-store", () => ({ + useUserStore: (sel?: (s: typeof userState) => unknown) => + sel ? sel(userState) : userState, })) const MOCK_ITEMS: WorkflowMarketplaceItem[] = [ @@ -47,13 +54,17 @@ vi.mock("@/lib/mock-data", () => ({ { ref_id: "rc-deduplication", source_type: "deduplication", kind: "janitor", enabled: true, label: "Deduplication" }, { ref_id: "rc-content-review", source_type: "content_review", kind: "janitor", enabled: false, label: "Content Review" }, ], + MOCK_STAKWORK_RUNS: [], })) import { WorkflowsPanel } from "@/components/layout/workflows-panel" describe("WorkflowsPanel", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + userState.isAdmin = false + const { isMocksEnabled } = await import("@/lib/mock-data") + vi.mocked(isMocksEnabled).mockReturnValue(false) }) it("renders loading spinner while fetching", async () => { @@ -196,4 +207,253 @@ describe("WorkflowsPanel", () => { expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument() }) }) + + // ── Admin accordion tests ──────────────────────────────────────────────── + + describe("admin accordion", () => { + beforeEach(() => { + userState.isAdmin = true + }) + + it("non-admin: cards render as plain divs — no buttons with aria-expanded, no chevrons", async () => { + userState.isAdmin = false + const { getWorkflowMarketplace } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + // Only filter chip buttons (All, Ingestion, Janitor) + Close button = 4 + const allButtons = screen.getAllByRole("button") + expect(allButtons.length).toBe(4) + expect(document.querySelector("[aria-expanded]")).toBeNull() + }) + + it("admin: cards are buttons with aria-expanded attribute", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + vi.mocked(getCronRuns).mockResolvedValue({ runs: [] }) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + const expandableButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + // 4 workflow cards + expect(expandableButtons.length).toBe(4) + }) + + it("clicking a card expands it showing runs section", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + vi.mocked(getCronRuns).mockResolvedValue({ runs: [] }) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + + await userEvent.click(cardButtons[0]) + + await waitFor(() => { + expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy() + }) + }) + + it("clicking an expanded card collapses it", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + vi.mocked(getCronRuns).mockResolvedValue({ runs: [] }) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + + // Expand + await userEvent.click(cardButtons[0]) + await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy()) + + // Collapse + await userEvent.click(cardButtons[0]) + await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeNull()) + }) + + it("opening a second card collapses the first (only one open at a time)", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + vi.mocked(getCronRuns).mockResolvedValue({ runs: [] }) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + + await userEvent.click(cardButtons[0]) + await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy()) + + await userEvent.click(cardButtons[1]) + await waitFor(() => { + const sections = document.querySelectorAll("[data-testid='runs-section']") + expect(sections.length).toBe(1) + }) + }) + + it("shows 'No runs yet' when API returns empty array", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + vi.mocked(getCronRuns).mockResolvedValue({ runs: [] }) + + render( {}} />) + await waitFor(() => screen.getByText("Deduplication")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + await userEvent.click(cardButtons[0]) + + await waitFor(() => { + expect(screen.getByText("No runs yet")).toBeInTheDocument() + }) + }) + + it("renders run rows with RunStatusBadge and relative timestamp", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + const mockRun: StakworkRun = { + ref_id: "run-001", + source_type: "twitter_handle", + kind: "source", + status: "completed", + finished_at: Math.floor(Date.now() / 1000) - 3600, + } + vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] }) + + render( {}} />) + await waitFor(() => screen.getByText("Twitter Handle")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + await userEvent.click(cardButtons[0]) + + await waitFor(() => { + expect(screen.getByText("completed")).toBeInTheDocument() + }) + }) + + it("renders 'View on Stakwork' external link when project_id is present", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + const mockRun: StakworkRun = { + ref_id: "run-001", + source_type: "twitter_handle", + kind: "source", + status: "completed", + finished_at: Math.floor(Date.now() / 1000) - 3600, + project_id: 123456, + } + vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] }) + + render( {}} />) + await waitFor(() => screen.getByText("Twitter Handle")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + await userEvent.click(cardButtons[0]) + + await waitFor(() => { + const link = document.querySelector("[data-testid='stakwork-link']") as HTMLAnchorElement + expect(link).toBeTruthy() + expect(link.href).toBe("https://jobs.stakwork.com/admin/projects/123456") + }) + }) + + it("does not render stakwork link when project_id is absent", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + const mockRun: StakworkRun = { + ref_id: "run-001", + source_type: "twitter_handle", + kind: "source", + status: "completed", + finished_at: Math.floor(Date.now() / 1000) - 3600, + } + vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] }) + + render( {}} />) + await waitFor(() => screen.getByText("Twitter Handle")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + await userEvent.click(cardButtons[0]) + + await waitFor(() => { + expect(document.querySelector("[data-testid='stakwork-link']")).toBeNull() + }) + }) + + it("renders error text below run row when error field is set", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + const mockRun: StakworkRun = { + ref_id: "run-002", + source_type: "twitter_handle", + kind: "source", + status: "error", + error: "Stakwork dispatch timeout", + finished_at: Math.floor(Date.now() / 1000) - 60, + } + vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] }) + + render( {}} />) + await waitFor(() => screen.getByText("Twitter Handle")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + await userEvent.click(cardButtons[0]) + + await waitFor(() => { + expect(screen.getByText("Stakwork dispatch timeout")).toBeInTheDocument() + }) + }) + + it("re-expanding a card does not call getCronRuns again (cache hit)", async () => { + const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api") + vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS) + vi.mocked(getCronRuns).mockResolvedValue({ runs: [] }) + + render( {}} />) + await waitFor(() => screen.getByText("Twitter Handle")) + + const cardButtons = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")) + + // Expand + await userEvent.click(cardButtons[0]) + await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy()) + + // Collapse + await userEvent.click(cardButtons[0]) + await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeNull()) + + // Re-expand + await userEvent.click(cardButtons[0]) + await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy()) + + // getCronRuns should have been called only once — cache used on re-expand + expect(vi.mocked(getCronRuns)).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 28a6c28..562fb6e 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -314,6 +314,7 @@ export interface StakworkRun { created_at?: number started_at?: number finished_at?: number + project_id?: string | number } export async function getCronConfig( diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index cd3e310..25545ce 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -785,6 +785,7 @@ export const MOCK_STAKWORK_RUNS: StakworkRun[] = [ created_at: mockRunTs(120), started_at: mockRunTs(119), finished_at: mockRunTs(110), + project_id: 123456, }, { ref_id: "run-002", @@ -832,6 +833,7 @@ export const MOCK_STAKWORK_RUNS: StakworkRun[] = [ trigger: "MANUAL", status: "pending", created_at: mockRunTs(1), + project_id: 789012, }, { ref_id: "mock-run-cr-1",