Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/components/layout/workflow-card-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { RadarSourceType, JanitorSourceType } from "@/lib/graph-api"
import type { LucideIcon } from "lucide-react"
import { AtSign, Video, Rss, Hash, Sparkles, Layers, ShieldCheck } from "lucide-react"

export interface WorkflowTypeMeta {
icon: LucideIcon
label: string
tone: string // Tailwind color group, e.g. "sky", "red", "violet"
}

export const WORKFLOW_TYPE_META: Record<RadarSourceType | JanitorSourceType, WorkflowTypeMeta> = {
twitter_handle: { icon: AtSign, label: "Twitter Handle", tone: "sky" },
youtube_channel: { icon: Video, label: "YouTube Channel", tone: "red" },
rss: { icon: Rss, label: "RSS Feed", tone: "orange" },
topic: { icon: Hash, label: "Topic", tone: "amber" },
deduplication: { icon: Layers, label: "Deduplication", tone: "violet" },
content_review: { icon: ShieldCheck, label: "Content Review", tone: "emerald" },
topic_review: { icon: Sparkles, label: "Topic Review", tone: "fuchsia" },
}

// Static tone → Tailwind class mapping to avoid Tailwind purge issues
export const TONE_CLASSES: Record<string, string> = {
sky: "bg-sky-500/10 text-sky-400 border-sky-500/20",
red: "bg-red-500/10 text-red-400 border-red-500/20",
orange: "bg-orange-500/10 text-orange-400 border-orange-500/20",
amber: "bg-amber-500/10 text-amber-400 border-amber-500/20",
violet: "bg-violet-500/10 text-violet-400 border-violet-500/20",
emerald: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
fuchsia: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20",
slate: "bg-slate-500/10 text-slate-400 border-slate-500/20",
}

// Returns item.label if set, otherwise the human-friendly label from meta
export function getWorkflowDisplayName(item: {
label?: string
source_type: RadarSourceType | JanitorSourceType
}): string {
return item.label ?? WORKFLOW_TYPE_META[item.source_type]?.label ?? item.source_type
}
67 changes: 44 additions & 23 deletions src/components/layout/workflows-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ 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 { cn } from "@/lib/utils"
import {
WORKFLOW_TYPE_META,
TONE_CLASSES,
getWorkflowDisplayName,
} from "./workflow-card-meta"

type FilterKind = "all" | CronKind

Expand All @@ -26,33 +31,49 @@ function kindBadgeClass(kind: CronKind): string {
}

function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) {
const label = item.label || item.source_type
const meta = WORKFLOW_TYPE_META[item.source_type]
const Icon = meta?.icon ?? Cpu
const tone = meta?.tone ?? "slate"
const displayName = getWorkflowDisplayName(item)

return (
<div className="flex items-center gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2.5 hover:bg-muted/30 transition-colors">
<span
<div className="rounded-xl border border-border/60 bg-card/40 px-3 py-3 hover:bg-card/60 hover:border-border transition-colors flex items-center gap-3">
{/* Tinted icon tile */}
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
item.enabled
? "bg-emerald-400 shadow-[0_0_6px_theme(colors.emerald.400)]"
: "bg-muted-foreground/30"
"h-10 w-10 shrink-0 rounded-lg flex items-center justify-center border",
TONE_CLASSES[tone]
)}
aria-label={item.enabled ? "Enabled" : "Disabled"}
data-testid={item.enabled ? "dot-enabled" : "dot-disabled"}
/>
>
<Icon className="h-4 w-4" data-testid={`workflow-icon-${item.source_type}`} />
</div>

{/* Name — single source of truth, no duplicate sub-text */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{label}</p>
<p className="text-[10px] font-mono text-muted-foreground/70 mt-0.5 truncate">
{item.source_type}
</p>
<p className="text-sm font-medium text-foreground truncate">{displayName}</p>
</div>

{/* Kind chip + enabled dot */}
<div className="flex items-center gap-1.5 shrink-0">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-mono font-medium",
kindBadgeClass(item.kind)
)}
>
{kindBadgeLabel(item.kind)}
</span>
<span
className={cn(
"h-2 w-2 rounded-full",
item.enabled
? "bg-emerald-400 shadow-[0_0_6px_theme(colors.emerald.400)]"
: "bg-muted-foreground/30"
)}
aria-label={item.enabled ? "Enabled" : "Disabled"}
data-testid={item.enabled ? "dot-enabled" : "dot-disabled"}
/>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-mono font-medium",
kindBadgeClass(item.kind)
)}
>
{kindBadgeLabel(item.kind)}
</span>
</div>
)
}
Expand Down Expand Up @@ -144,7 +165,7 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
</p>
</div>
) : (
<div className="py-2 px-3 flex flex-col gap-1.5">
<div className="py-2 px-3 flex flex-col gap-2">
{filtered.map((item) => (
<WorkflowCard key={item.ref_id} item={item} />
))}
Expand Down
28 changes: 18 additions & 10 deletions src/lib/__tests__/workflows-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,18 @@ describe("WorkflowsPanel", () => {
render(<WorkflowsPanel onClose={() => {}} />)

await waitFor(() => {
// twitter_handle appears twice (label fallback + sub-text); ensure at least one exists
expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
// twitter_handle has no label — humanized name "Twitter Handle" appears once
expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.getByText("YouTube Channel")).toBeInTheDocument()
expect(screen.getByText("Deduplication")).toBeInTheDocument()
expect(screen.getByText("Content Review")).toBeInTheDocument()
})

// Icon tiles render with data-testid per source_type
expect(document.querySelector("[data-testid='workflow-icon-twitter_handle']")).toBeTruthy()
expect(document.querySelector("[data-testid='workflow-icon-youtube_channel']")).toBeTruthy()
expect(document.querySelector("[data-testid='workflow-icon-deduplication']")).toBeTruthy()
expect(document.querySelector("[data-testid='workflow-icon-content_review']")).toBeTruthy()
})

it("falls back to source_type when label is absent", async () => {
Expand All @@ -86,8 +92,9 @@ describe("WorkflowsPanel", () => {
render(<WorkflowsPanel onClose={() => {}} />)

await waitFor(() => {
// rc-twitter has no label — source_type is rendered as both primary text and sub-text
expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
// rc-twitter has no label — humanized display name shown once, raw source_type not visible
expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
})
})

Expand Down Expand Up @@ -123,8 +130,8 @@ describe("WorkflowsPanel", () => {

await userEvent.click(screen.getByRole("button", { name: "Ingestion" }))

// Source items should be visible (twitter_handle may appear twice as label+sub-text)
expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
// Source items should be visible
expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.getByText("YouTube Channel")).toBeInTheDocument()
// Janitor items should not be visible
expect(screen.queryByText("Deduplication")).not.toBeInTheDocument()
Expand All @@ -144,7 +151,7 @@ describe("WorkflowsPanel", () => {
expect(screen.getByText("Deduplication")).toBeInTheDocument()
expect(screen.getByText("Content Review")).toBeInTheDocument()
// Source items should not be visible
expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
expect(screen.queryByText("Twitter Handle")).not.toBeInTheDocument()
expect(screen.queryByText("YouTube Channel")).not.toBeInTheDocument()
})

Expand All @@ -159,7 +166,7 @@ describe("WorkflowsPanel", () => {
await userEvent.click(screen.getByRole("button", { name: "Janitor" }))
await userEvent.click(screen.getByRole("button", { name: "All" }))

expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.getByText("Deduplication")).toBeInTheDocument()
})

Expand All @@ -184,8 +191,9 @@ describe("WorkflowsPanel", () => {
render(<WorkflowsPanel onClose={() => {}} />)

await waitFor(() => {
// MOCK_ITEMS has twitter_handle (no label) — appears as both primary text and sub-text
expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
// twitter_handle item has no label — humanized name shown once
expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
})
})
})
Loading