diff --git a/frontend/components/investigations/InvestigationForm.test.tsx b/frontend/components/investigations/InvestigationForm.test.tsx index aacbc549..7c15e0d3 100644 --- a/frontend/components/investigations/InvestigationForm.test.tsx +++ b/frontend/components/investigations/InvestigationForm.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { InvestigationForm } from "@/components/investigations/InvestigationForm"; import { api } from "@/lib/api"; @@ -155,6 +155,11 @@ describe("InvestigationForm", () => { vi.spyOn(api, "listPlaybooks").mockResolvedValue([ { id: "investigation", source: "system", locked: true, nodeCount: 1, yaml: "", syncState: synced, type: "general" }, { id: "release_verification", symptom: "Verify a release", source: "user", nodeCount: 1, yaml: "", syncState: synced, type: "general" }, + { id: "pod_crashloop", symptom: "Pods restart in a loop", source: "plugin", nodeCount: 1, yaml: "", syncState: synced, type: "investigation" }, + ]); + vi.spyOn(api, "listPlaybookTypes").mockResolvedValue([ + { name: "investigation", description: "Hunt a live incident", source: "system", tracked: true }, + { name: "general", description: "Routine operational work", source: "system", tracked: true }, ]); } @@ -175,11 +180,40 @@ describe("InvestigationForm", () => { render(); await screen.findByPlaceholderText("enter notes"); await screen.findByRole("option", { name: /release_verification/ }); - expect(screen.queryByRole("option", { name: /^investigation/ })).toBeNull(); + expect(within(screen.getByLabelText(/^Playbook/i)).queryByRole("option", { name: /^investigation/ })).toBeNull(); - fireEvent.change(screen.getByLabelText(/Playbook/i), { target: { value: "release_verification" } }); + fireEvent.change(screen.getByLabelText(/^Playbook/i), { target: { value: "release_verification" } }); fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: "release_verification" })); }); + + it("filters playbooks by category and describes both picks", async () => { + setup(); + render( {}} />); + await screen.findByRole("option", { name: /release_verification/ }); + + fireEvent.change(screen.getByLabelText(/Category/i), { target: { value: "general" } }); + expect(screen.getByText("Routine operational work")).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /pod_crashloop/ })).toBeNull(); + expect(screen.getByRole("option", { name: /release_verification/ })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/^Playbook/i), { target: { value: "release_verification" } }); + expect(screen.getByText("Verify a release")).toBeInTheDocument(); + }); + + it("clears a chosen playbook when the category no longer contains it", async () => { + setup(); + const onSubmit = vi.fn(); + render(); + await screen.findByRole("option", { name: /release_verification/ }); + + fireEvent.change(screen.getByLabelText(/^Playbook/i), { target: { value: "release_verification" } }); + // Picking a playbook snaps the category to its type. + expect((screen.getByLabelText(/Category/i) as HTMLSelectElement).value).toBe("general"); + + fireEvent.change(screen.getByLabelText(/Category/i), { target: { value: "investigation" } }); + fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: undefined })); + }); }); }); diff --git a/frontend/components/investigations/InvestigationForm.tsx b/frontend/components/investigations/InvestigationForm.tsx index 9f64b3a6..ff593583 100644 --- a/frontend/components/investigations/InvestigationForm.tsx +++ b/frontend/components/investigations/InvestigationForm.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { api, type InputSchema, type PlaybookListItem, type PromOverride } from "@/lib/api"; -import { selectablePlaybooks } from "@/lib/playbook-select"; +import { api, type InputSchema, type PlaybookListItem, type PlaybookTypeItem, type PromOverride } from "@/lib/api"; +import { groupPlaybooks } from "@/lib/playbook-select"; import { ArrowRightIcon } from "@/components/shared/Icons"; import { Spinner } from "@/components/shared/Spinner"; import { TextInput } from "@/components/inputs/TextInput"; @@ -29,6 +29,8 @@ export function InvestigationForm({ onSubmit }: Props) { const [values, setValues] = useState>>({}); const [auto, setAuto] = useState(false); const [playbooks, setPlaybooks] = useState([]); + const [playbookTypes, setPlaybookTypes] = useState([]); + const [category, setCategory] = useState(""); const [playbook, setPlaybook] = useState(""); // Prom override panel state — unchanged from today. @@ -44,8 +46,14 @@ export function InvestigationForm({ onSubmit }: Props) { // can't be fetched; the operator can still start a session. api .listPlaybooks() - .then((items) => setPlaybooks(selectablePlaybooks(items))) + .then(setPlaybooks) .catch(() => setPlaybooks([])); + // Type descriptions only decorate the category picker; a failed + // fetch leaves the groups undescribed, not the picker empty. + api + .listPlaybookTypes() + .then(setPlaybookTypes) + .catch(() => setPlaybookTypes([])); // Pre-populate prom override fields with the profile's defaults so the // operator sees what's currently configured. Functional setters so a // late-arriving fetch can't overwrite values the operator typed while @@ -83,6 +91,23 @@ export function InvestigationForm({ onSubmit }: Props) { setValues((prev) => ({ ...prev, [id]: next })); } + const groups = groupPlaybooks(playbooks, playbookTypes); + const activeGroup = groups.find((g) => g.name === category); + const visiblePlaybooks = activeGroup ? activeGroup.playbooks : groups.flatMap((g) => g.playbooks); + const chosenPlaybook = visiblePlaybooks.find((p) => p.id === playbook); + + function pickCategory(next: string) { + setCategory(next); + const stillVisible = groups.find((g) => g.name === next)?.playbooks.some((p) => p.id === playbook); + if (next && !stillVisible) setPlaybook(""); + } + + function pickPlaybook(next: string) { + setPlaybook(next); + const owner = groups.find((g) => g.playbooks.some((p) => p.id === next)); + if (owner) setCategory(owner.name); + } + return (
{schema.map((s) => { @@ -137,26 +162,51 @@ export function InvestigationForm({ onSubmit }: Props) { } })} - {playbooks.length > 0 && ( -