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:
@@ -1,18 +1,49 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import type { ProgramSummary } from "@/types"
|
import type { ProgramSummary } from "@/types"
|
||||||
import { ProgramCard } from "./ProgramCard"
|
import { ProgramCard } from "./ProgramCard"
|
||||||
|
import { kindLabel } from "@/lib/labels"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ProgramListProps {
|
interface ProgramListProps {
|
||||||
programs: ProgramSummary[]
|
programs: ProgramSummary[]
|
||||||
linkBase?: string // where each card links (default "/programs")
|
linkBase?: string // where each card links (default "/programs")
|
||||||
showDeployments?: boolean // list each program's deployments on the card (default true)
|
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("")
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
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) {
|
if (search) {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
base = base.filter(
|
base = base.filter(
|
||||||
@@ -22,17 +53,36 @@ export function ProgramList({ programs, linkBase, showDeployments }: ProgramList
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}, [programs, search])
|
}, [programs, search, kind])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Filter programs..."
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
@@ -52,3 +102,29 @@ export function ProgramList({ programs, linkBase, showDeployments }: ProgramList
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Programs() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-[var(--muted)]">Loading...</p>
|
<p className="text-[var(--muted)]">Loading...</p>
|
||||||
) : programs && programs.length > 0 ? (
|
) : programs && programs.length > 0 ? (
|
||||||
<ProgramList programs={programs} />
|
<ProgramList programs={programs} filterable />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--muted)]">No programs yet.</p>
|
<p className="text-[var(--muted)]">No programs yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user