From 9f50be3f7b19e2fa4a8e40ebfb5ca19d0707c155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:04:11 +0200 Subject: [PATCH 1/2] feat(frontend): split the session-start playbook picker into category and playbook selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single flat select listed every playbook as "id — symptom" in one long unwrapped line. It is now two selects side by side, capped at max-w-2xl: a Category select over the playbook types (investigation first, then alphabetical) that filters the Playbook select next to it. The active category's description from GET /api/playbook-types renders under the first select, and the chosen playbook's symptom (or description) wraps under the second. Picking a playbook snaps the category to its type; switching to a category that no longer contains the chosen playbook clears it back to the guided default. groupPlaybooks in lib/playbook-select.ts owns the grouping so the form only renders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017UzTvd2zctxr2LYid6SucC --- .../investigations/InvestigationForm.test.tsx | 40 +++++++- .../investigations/InvestigationForm.tsx | 92 +++++++++++++++---- frontend/lib/playbook-select.test.ts | 28 +++++- frontend/lib/playbook-select.ts | 32 ++++++- 4 files changed, 168 insertions(+), 24 deletions(-) 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..164edec9 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,55 @@ export function InvestigationForm({ onSubmit }: Props) { } })} - {playbooks.length > 0 && ( -