Dashboard: kind filters on Programs, tools off Services page, in-app confirm modals
- /services was returning every non-job deployment (tools, statics, remotes); filter it to kind==service so the Services page shows only services. - Programs page gains kind-filter chips (All / Service / Job / Tool / Static / Reference) with per-kind counts, colored to match KindBadge. - Replace window.confirm with a reusable ConfirmModal (styled, danger variant, Esc/Enter, preserves bulleted bodies); FormFooter's delete uses it, so program/ service/job deletion now confirms in-app with the enumerated cascade list. castle-api 58 passed; /services live drops from 37 to 15 (services only).
This commit is contained in:
82
app/src/components/ConfirmModal.tsx
Normal file
82
app/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from "react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
/** Optional body; multi-line strings render with their line breaks preserved. */
|
||||
body?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** A small in-app confirmation dialog. Replaces window.confirm so destructive
|
||||
* actions get a styled, readable prompt (bulleted bodies, a danger variant). */
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel()
|
||||
if (e.key === "Enter") onConfirm()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open, onCancel, onConfirm])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-start gap-3 px-5 py-4 border-b border-[var(--border)]">
|
||||
{danger && <AlertTriangle size={18} className="text-red-400 mt-0.5 shrink-0" />}
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
{body && (
|
||||
<div className="px-5 py-4 text-sm text-[var(--muted)] whitespace-pre-wrap">
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 px-5 py-3 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
autoFocus
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded text-white transition-colors",
|
||||
danger ? "bg-red-700 hover:bg-red-600" : "bg-blue-700 hover:bg-blue-600",
|
||||
)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,75 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
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 }: ProgramListProps) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [kind, setKind] = useState<string | null>(null)
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<string, number> = {}
|
||||
for (const p of programs) if (p.kind) c[p.kind] = (c[p.kind] ?? 0) + 1
|
||||
return c
|
||||
}, [programs])
|
||||
const kindsPresent = KIND_ORDER.filter((k) => counts[k])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
||||
if (!search) return base
|
||||
const q = search.toLowerCase()
|
||||
return base.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}, [programs, search])
|
||||
let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
||||
if (kind) base = base.filter((p) => p.kind === kind)
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
base = base.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}
|
||||
return base
|
||||
}, [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"
|
||||
/>
|
||||
<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 ? (
|
||||
@@ -43,3 +84,29 @@ export function ProgramList({ programs }: ProgramListProps) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { SecretsEditor } from "@/components/SecretsEditor"
|
||||
import { ConfirmModal } from "@/components/ConfirmModal"
|
||||
|
||||
const INPUT =
|
||||
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||
@@ -185,15 +186,14 @@ export function FormFooter({
|
||||
/** When set, removal is disallowed and this reason is shown instead of the button. */
|
||||
deleteBlocked?: string
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
||||
{deleteBlocked ? (
|
||||
<span className="text-xs text-amber-400">{deleteBlocked}</span>
|
||||
) : onDelete ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(confirmMessage ?? `${deleteLabel}?`)) onDelete()
|
||||
}}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 size={12} /> {deleteLabel}
|
||||
@@ -208,6 +208,20 @@ export function FormFooter({
|
||||
>
|
||||
{saving ? "Saving…" : saved ? "Saved" : "Save"}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
title={deleteLabel}
|
||||
body={confirmMessage}
|
||||
confirmLabel={deleteLabel}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
setConfirmOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user