Programs page: deployment-kind filter chips (opt-in)

ProgramList gains a filterable prop; the Programs page enables it. Chips filter by
whether a program has a deployment of that kind (membership over program.deployments),
so a tool-and-job program shows under both Tool and Job. Counts are programs-per-kind.
Tools page stays unfiltered (already scoped).
This commit is contained in:
2026-07-01 13:10:13 -07:00
parent 05031a1395
commit 60cbb8a844
2 changed files with 80 additions and 4 deletions

View File

@@ -1,18 +1,49 @@
import { useMemo, useState } from "react"
import type { ProgramSummary } from "@/types"
import { ProgramCard } from "./ProgramCard"
import { kindLabel } from "@/lib/labels"
import { cn } from "@/lib/utils"
interface ProgramListProps {
programs: ProgramSummary[]
linkBase?: string // where each card links (default "/programs")
showDeployments?: boolean // list each program's deployments on the card (default true)
filterable?: boolean // show deployment-kind filter chips (default false)
}
export function ProgramList({ programs, linkBase, showDeployments }: ProgramListProps) {
const KIND_ORDER = ["service", "job", "tool", "static", "reference"]
// Active chip color per kind — mirrors KindBadge so the filter reads as the badge.
const KIND_ACTIVE: Record<string, string> = {
service: "bg-green-700 text-white border-green-600",
job: "bg-purple-700 text-white border-purple-600",
tool: "bg-blue-700 text-white border-blue-600",
static: "bg-cyan-700 text-white border-cyan-600",
reference: "bg-gray-600 text-gray-200 border-gray-500",
}
export function ProgramList({ programs, linkBase, showDeployments, filterable }: ProgramListProps) {
const [search, setSearch] = useState("")
// Filter by a *deployment* kind: a program matches if it has a deployment of
// this kind (so a tool-and-job program shows under both Tool and Job).
const [kind, setKind] = useState<string | null>(null)
// Count programs per deployment kind (a program counts once toward each kind
// it deploys as).
const counts = useMemo(() => {
const c: Record<string, number> = {}
for (const p of programs) {
for (const k of new Set(p.deployments.map((d) => d.kind))) {
c[k] = (c[k] ?? 0) + 1
}
}
return c
}, [programs])
const kindsPresent = KIND_ORDER.filter((k) => counts[k])
const filtered = useMemo(() => {
let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
if (kind) base = base.filter((p) => p.deployments.some((d) => d.kind === kind))
if (search) {
const q = search.toLowerCase()
base = base.filter(
@@ -22,17 +53,36 @@ export function ProgramList({ programs, linkBase, showDeployments }: ProgramList
)
}
return base
}, [programs, search])
}, [programs, search, kind])
return (
<div>
<div className="mb-4">
<div className="mb-4 flex flex-wrap items-center gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter programs..."
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
/>
{filterable && kindsPresent.length > 0 && (
<div className="flex flex-wrap gap-1.5">
<Chip
label={`All (${programs.length})`}
active={kind === null}
activeClass="bg-[var(--primary)] text-white border-[var(--primary)]"
onClick={() => setKind(null)}
/>
{kindsPresent.map((k) => (
<Chip
key={k}
label={`${kindLabel(k)} (${counts[k]})`}
active={kind === k}
activeClass={KIND_ACTIVE[k]}
onClick={() => setKind(kind === k ? null : k)}
/>
))}
</div>
)}
</div>
{filtered.length === 0 ? (
@@ -52,3 +102,29 @@ export function ProgramList({ programs, linkBase, showDeployments }: ProgramList
</div>
)
}
function Chip({
label,
active,
activeClass,
onClick,
}: {
label: string
active: boolean
activeClass: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"text-xs px-2.5 py-1 rounded-full border transition-colors",
active
? activeClass
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]",
)}
>
{label}
</button>
)
}

View File

@@ -12,7 +12,7 @@ export function Programs() {
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : programs && programs.length > 0 ? (
<ProgramList programs={programs} />
<ProgramList programs={programs} filterable />
) : (
<p className="text-[var(--muted)]">No programs yet.</p>
)}