diff --git a/.changeset/fn-7301-new-task-workflow-dropdown.md b/.changeset/fn-7301-new-task-workflow-dropdown.md new file mode 100644 index 0000000000..114394323e --- /dev/null +++ b/.changeset/fn-7301-new-task-workflow-dropdown.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show workflow icons in the full New Task workflow picker. +category: feature +dev: Replaces the create-time TaskForm workflow native select with an icon-capable styled dropdown while preserving workflowId payload semantics. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c4aa7c4757..e401fead2c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -232,6 +232,9 @@ Behavior: Workflows define how a task moves through planning, execution, review, workflow steps, merge, and any custom graph policy. Most coding tasks can stay on the default Coding workflow, but task and board workflow controls can select a different built-in or custom workflow per task. For the built-in catalog and runtime semantics, see [Workflow Steps → Workflow overview](./workflow-steps.md#workflow-overview). + +When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow. + The workflow editor opens as a full-screen modal editor for inspecting built-ins and authoring custom workflows. Navigation: diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index b652bf3c18..07ca5e2f03 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -438,6 +438,81 @@ The GitHub reference picker is a compact prompt-seeding helper inside the primar gap: var(--space-sm); } +/* +FNXC:NewTaskWorkflowDropdown 2026-06-30-18:35: +The New Task create workflow selector is a real dropdown so workflow identity icons can appear in the trigger and option rows. Keep all sizing token-based and bounded inside both the floating desktop panel and the mobile full-screen sheet. +*/ +.task-workflow-dropdown-wrap { + position: relative; + width: 100%; + min-width: 0; +} + +.task-workflow-dropdown-trigger { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: var(--space-xs); + width: 100%; + min-width: 0; + max-width: 100%; +} + +.task-form-inline-workflow-label, +.task-workflow-trigger-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-workflow-trigger-icon, +.task-workflow-option-icon { + flex: 0 0 auto; +} + +.task-workflow-dropdown-menu { + width: min(calc(var(--space-xl) * 18), 100%); + min-width: min(calc(var(--space-xl) * 12), 100%); + max-width: 100%; + max-height: min(calc(var(--space-xl) * 12), calc(100dvh - var(--space-xl) * 2)); + overflow-y: auto; + overscroll-behavior: contain; +} + +.task-workflow-dropdown-option { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: left; + align-items: flex-start; +} + +.task-workflow-option-copy { + display: flex; + flex-direction: column; + gap: var(--space-2xs); + min-width: 0; + flex: 1 1 auto; +} + +.task-workflow-option-name, +.task-workflow-option-id { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-workflow-default-badge { + flex: 0 0 auto; + color: var(--text-muted); + font-size: var(--font-size-xs); + line-height: 1; +} + .workflow-steps-description { margin-bottom: var(--space-xs); display: block; diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index a9b02c6b6b..992d3f1ea3 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -11,6 +11,7 @@ import { LoadingSpinner } from "./LoadingSpinner"; import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2, Paperclip, Flag, Zap, Brain, Server } from "lucide-react"; import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking"; import { ProviderIcon } from "./ProviderIcon"; +import { WorkflowIcon } from "./WorkflowIcon"; function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string { if (status === "online") return t("taskForm.nodeStatusOnline", "Online"); @@ -253,6 +254,7 @@ export function TaskForm({ (githubRepoOverride || "") !== ""; const [showDepDropdown, setShowDepDropdown] = useState(false); + const [showWorkflowDropdown, setShowWorkflowDropdown] = useState(false); const executionModeRef = useRef(executionMode); useEffect(() => { executionModeRef.current = executionMode; @@ -285,6 +287,7 @@ export function TaskForm({ const refineMenuRef = useRef(null); const depDropdownRef = useRef(null); + const workflowDropdownRef = useRef(null); const descTextareaRef = useRef(null); const titleInputRef = useRef(null); const fileInputRef = useRef(null); @@ -487,6 +490,7 @@ export function TaskForm({ if (moreOptionsOpen) return; setShowDepDropdown(false); setDepSearch(""); + setShowWorkflowDropdown(false); }, [moreOptionsOpen]); // Auto-select title input text in edit mode (focus is handled by autoFocus) @@ -510,6 +514,17 @@ export function TaskForm({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [showDepDropdown]); + useEffect(() => { + if (!showWorkflowDropdown) return; + const handleClickOutside = (e: MouseEvent) => { + if (workflowDropdownRef.current && !workflowDropdownRef.current.contains(e.target as Node)) { + setShowWorkflowDropdown(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showWorkflowDropdown]); + // Exit description fullscreen mode when edit controls are unavailable useEffect(() => { if (mode !== "edit" || disabled) { @@ -735,12 +750,31 @@ export function TaskForm({ // U6/R3: the project default workflow id (preselected + "(default)" badged). const defaultWorkflowId = settings?.defaultWorkflowId ?? null; + const inheritedWorkflowId = defaultWorkflowId ?? (settings ? "builtin:coding" : null); + const defaultWorkflow = defaultWorkflowId ? workflows.find((workflow) => workflow.id === defaultWorkflowId) : undefined; const selectedWorkflow = selectedWorkflowId === null ? null - : workflows.find((workflow) => workflow.id === (selectedWorkflowId ?? defaultWorkflowId)); + : workflows.find((workflow) => workflow.id === (selectedWorkflowId ?? inheritedWorkflowId)); + const selectedWorkflowValue = selectedWorkflowId === null + ? "__none__" + : selectedWorkflowId === undefined + ? (inheritedWorkflowId ?? "") + : selectedWorkflowId; + const workflowNameCounts = workflows.reduce((counts, workflow) => { + counts.set(workflow.name, (counts.get(workflow.name) ?? 0) + 1); + return counts; + }, new Map()); + const workflowOptionLabel = (workflow: WorkflowDefinition) => { + const duplicateName = (workflowNameCounts.get(workflow.name) ?? 0) > 1; + return duplicateName ? `${workflow.name} (${workflow.id})` : workflow.name; + }; const workflowInlineLabel = selectedWorkflowId === null ? t("taskForm.workflowNone", "No workflow") - : selectedWorkflow?.name ?? t("taskForm.workflowInlineDefault", "Normal"); + : selectedWorkflow ? workflowOptionLabel(selectedWorkflow) : t("taskForm.workflowInlineDefault", "Normal"); + const orderedWorkflowOptions = [ + ...(defaultWorkflow ? [defaultWorkflow] : []), + ...workflows.filter((workflow) => workflow.id !== defaultWorkflowId), + ]; const selectedNode = (nodeOptions ?? []).find((node) => node.id === nodeId); const nodeInlineLabel = selectedNode?.name ?? t("taskForm.nodeInlineDefault", "Node"); const modelInlineLabel = selectedPreset?.name ?? (presetMode === "custom" ? t("taskForm.modelsCustom", "Models") : t("taskForm.modelsDefault", "Models")); @@ -1014,13 +1048,19 @@ export function TaskForm({ )} @@ -1582,7 +1622,7 @@ export function TaskForm({ selection is materialized atomically server-side via `workflowId`. */} {onWorkflowIdChange && (
- + {workflowsLoading ? (
@@ -1595,40 +1635,90 @@ export function TaskForm({ {t("taskForm.workflowsCta", "Set up workflows in the editor")}
) : ( - +
)} {t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")} diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index f9c3387e13..c388a571e9 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -98,6 +98,20 @@ function makeTask(id: string): Task { }; } + +async function chooseWorkflowOption(value: string) { + const trigger = await screen.findByTestId("task-workflow-dropdown-trigger"); + fireEvent.click(trigger); + const optionTestId = value === "__none__" ? "task-workflow-option-none" : `task-workflow-option-${value}`; + fireEvent.click(await screen.findByTestId(optionTestId)); +} + +async function openWorkflowDropdown() { + const trigger = await screen.findByTestId("task-workflow-dropdown-trigger"); + fireEvent.click(trigger); + return screen.findByTestId("task-workflow-dropdown-menu"); +} + function renderNewTaskModal(props: Partial> = {}) { const defaultProps: ComponentProps = { isOpen: true, @@ -579,7 +593,8 @@ describe("NewTaskModal", () => { fireEvent.click(screen.getByTestId("task-form-inline-workflow")); await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden")); - expect(await screen.findByTestId("task-workflow-select")).toBeInTheDocument(); + expect(await screen.findByTestId("task-workflow-dropdown-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("task-workflow-dropdown-menu")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden"); @@ -816,7 +831,7 @@ describe("NewTaskModal", () => { fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Verify the login page" }, }); - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + await chooseWorkflowOption("wf-x"); const trigger = await screen.findByTestId("task-form-inline-optional-steps"); expect(trigger).toHaveTextContent("Steps: none"); @@ -837,7 +852,7 @@ describe("NewTaskModal", () => { vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]); renderNewTaskModal(); - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + await chooseWorkflowOption("wf-x"); await screen.findByTestId("task-form-inline-optional-steps"); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); @@ -853,7 +868,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } }); - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + await chooseWorkflowOption("wf-x"); const trigger = await screen.findByTestId("task-form-inline-optional-steps"); await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected")); @@ -873,7 +888,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast before metadata" } }); - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + await chooseWorkflowOption("wf-x"); await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-x", undefined)); fireEvent.click(screen.getByTestId("task-form-inline-fast")); @@ -893,7 +908,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task" } }); - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + await chooseWorkflowOption("wf-x"); const trigger = await screen.findByTestId("task-form-inline-optional-steps"); await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected")); @@ -910,7 +925,7 @@ describe("NewTaskModal", () => { vi.mocked(props.onCreateTask).mockClear(); vi.mocked(props.onCreateTask).mockResolvedValue(makeTask("FN-002")); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task with browser" } }); - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + await chooseWorkflowOption("wf-x"); const nextTrigger = await screen.findByTestId("task-form-inline-optional-steps"); fireEvent.click(screen.getByTestId("task-form-inline-fast")); fireEvent.click(nextTrigger); @@ -932,7 +947,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } }); // "No workflow" → null selection → no optional-steps fetch, no dropdown. - fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "__none__" } }); + await chooseWorkflowOption("__none__"); expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -1427,7 +1442,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy(); }); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit default" } }); @@ -1445,10 +1460,10 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy(); }); - fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } }); + await chooseWorkflowOption("WF-1"); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Pick a workflow" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -1464,12 +1479,12 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy(); }); // Pick a workflow, then switch to "No workflow" to register an explicit null. - fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } }); - fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } }); + await chooseWorkflowOption("WF-1"); + await chooseWorkflowOption("__none__"); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -1485,7 +1500,7 @@ describe("NewTaskModal", () => { renderNewTaskModal(); await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy(); }); expect(screen.queryByTestId("workflow-step-order")).toBeNull(); expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull(); @@ -1902,6 +1917,22 @@ describe("NewTaskModal", () => { FNXC:NewTask 2026-06-22-20:30: On desktop the New Task dialog is a floating, draggable, resizable, NON-BLOCKING window: the overlay is `pointer-events: none` and aria-modal="false" so behind-clicks pass through and never close the dialog (only the header X / Cancel / Escape dismiss). It carries a draggable header handle and resize handles. */ + describe("workflow dropdown styling", () => { + it("uses tokenized bounded dropdown styles without legacy native-select assumptions", () => { + const workflowRules = Array.from(newTaskModalCss.matchAll(/\.task-workflow[^,{\s]*(?:[^{}]*)\{([^}]*)\}/g)) + .map((match) => match[0]) + .join("\n"); + + expect(newTaskModalCss).toContain("FNXC:NewTaskWorkflowDropdown 2026-06-30"); + expect(workflowRules).toContain("var(--space-"); + expect(workflowRules).toContain("max-width: 100%"); + expect(workflowRules).toContain("overflow-y: auto"); + expect(workflowRules).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgb\(/); + expect(newTaskModalCss).toMatch(/@media \(max-width: 768px\)[\s\S]*\.task-form \.dep-dropdown/); + expect(newTaskModalCss).not.toMatch(/task-workflow-select\s*\{/); + }); + }); + describe("desktop floating window", () => { beforeEach(() => { mockViewportMode = "desktop"; diff --git a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx index e241ca5cea..dbce77e296 100644 --- a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx @@ -87,6 +87,12 @@ function renderTaskForm(props: Partial> = return { ...result, props: mergedProps }; } +async function openWorkflowDropdown() { + const trigger = await screen.findByTestId("task-workflow-dropdown-trigger"); + fireEvent.click(trigger); + return screen.getByTestId("task-workflow-dropdown-menu"); +} + function renderTaskFormWithDescriptionState(props: Partial> = {}) { const defaultProps: React.ComponentProps = { mode: "edit", @@ -763,13 +769,11 @@ describe("TaskForm", () => { const onWorkflowIdChange = vi.fn(); renderTaskForm({ onWorkflowIdChange }); - await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); - }); + await openWorkflowDropdown(); - const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; - fireEvent.change(select, { target: { value: "WF-1" } }); + fireEvent.click(screen.getByTestId("task-workflow-option-WF-1")); expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1"); + expect(screen.queryByTestId("task-workflow-select")).toBeNull(); }); it("disables all inputs when disabled prop is true", () => { @@ -1111,12 +1115,13 @@ describe("TaskForm workflow picker (U6/R3)", () => { vi.clearAllMocks(); }); - async function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) { + async function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment"; icon?: string }>) { const { fetchWorkflows } = await import("../../api"); vi.mocked(fetchWorkflows).mockResolvedValueOnce( defs.map((d) => ({ id: d.id, name: d.name, + icon: d.icon, description: "", kind: d.kind ?? "workflow", ir: { version: "v1", name: d.name, nodes: [], edges: [] }, @@ -1131,12 +1136,11 @@ describe("TaskForm workflow picker (U6/R3)", () => { await mockWorkflows([{ id: "WF-1", name: "QA" }]); renderTaskForm({ onWorkflowIdChange: vi.fn() }); - await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); - }); - const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; - expect(select.options[0].textContent).toBe("No workflow"); + const menu = await openWorkflowDropdown(); + expect(menu.querySelector('[data-testid="task-workflow-option-none"]')).toBeTruthy(); + expect(Array.from(menu.querySelectorAll('[role="option"]'))[0]).toHaveAttribute("data-testid", "task-workflow-option-none"); expect(screen.getByTestId("task-workflow-help")).toBeTruthy(); + expect(screen.queryByTestId("task-workflow-select")).toBeNull(); }); it("badges the project default workflow with (default)", async () => { @@ -1155,9 +1159,59 @@ describe("TaskForm workflow picker (U6/R3)", () => { renderTaskForm({ onWorkflowIdChange: vi.fn() }); await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy(); }); - expect(screen.getByText("QA (default)")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toHaveTextContent("QA"); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toHaveTextContent("(default)"); + await openWorkflowDropdown(); + expect(screen.getByTestId("task-workflow-option-WF-1")).toHaveTextContent("(default)"); + }); + + it("renders built-in and custom icons without empty custom shells", async () => { + await mockWorkflows([ + { id: "builtin:coding", name: "Coding" }, + { id: "WF-CUSTOM", name: "Custom", icon: "🧪" }, + { id: "WF-PLAIN", name: "Plain" }, + ]); + renderTaskForm({ onWorkflowIdChange: vi.fn(), selectedWorkflowId: "builtin:coding" }); + + await openWorkflowDropdown(); + const builtin = screen.getByTestId("task-workflow-option-builtin:coding"); + const custom = screen.getByTestId("task-workflow-option-WF-CUSTOM"); + const plain = screen.getByTestId("task-workflow-option-WF-PLAIN"); + expect(builtin.querySelector(".workflow-icon--builtin")).toBeTruthy(); + expect(custom.querySelector(".workflow-icon--custom")).toHaveTextContent("🧪"); + expect(plain.querySelector(".workflow-icon")).toBeNull(); + expect(screen.getByTestId("task-workflow-dropdown-trigger").querySelector(".workflow-icon--builtin")).toBeTruthy(); + }); + + it("shows inherited builtin workflow instead of selecting No workflow when project default is unset", async () => { + await mockWorkflows([ + { id: "builtin:coding", name: "Coding" }, + { id: "WF-1", name: "QA" }, + ]); + renderTaskForm({ onWorkflowIdChange: vi.fn(), selectedWorkflowId: undefined }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toHaveTextContent("Coding"); + }); + expect(screen.getByTestId("task-workflow-dropdown-trigger").querySelector(".workflow-icon--builtin")).toBeTruthy(); + await openWorkflowDropdown(); + expect(screen.getByTestId("task-workflow-option-none")).toHaveAttribute("aria-selected", "false"); + expect(screen.getByTestId("task-workflow-option-builtin:coding")).toHaveAttribute("aria-selected", "true"); + }); + + it("disambiguates duplicate workflow names by id in aria text and subtitle", async () => { + await mockWorkflows([ + { id: "WF-A", name: "Review" }, + { id: "WF-B", name: "Review" }, + ]); + renderTaskForm({ onWorkflowIdChange: vi.fn() }); + + await openWorkflowDropdown(); + expect(screen.getByTestId("task-workflow-option-WF-A")).toHaveAttribute("aria-label", "Review (WF-A)"); + expect(screen.getByTestId("task-workflow-option-WF-A")).toHaveTextContent("WF-A"); + expect(screen.getByTestId("task-workflow-option-WF-B")).toHaveTextContent("WF-B"); }); it("excludes fragments from the dropdown", async () => { @@ -1167,13 +1221,9 @@ describe("TaskForm workflow picker (U6/R3)", () => { ]); renderTaskForm({ onWorkflowIdChange: vi.fn() }); - await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); - }); - const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; - const labels = Array.from(select.options).map((o) => o.textContent); - expect(labels).toContain("QA"); - expect(labels).not.toContain("Doc Fragment"); + await openWorkflowDropdown(); + expect(screen.getByText("QA")).toBeTruthy(); + expect(screen.queryByText("Doc Fragment")).toBeNull(); }); it("passes the chosen workflow id via onWorkflowIdChange", async () => { @@ -1181,11 +1231,8 @@ describe("TaskForm workflow picker (U6/R3)", () => { const onWorkflowIdChange = vi.fn(); renderTaskForm({ onWorkflowIdChange }); - await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); - }); - const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; - fireEvent.change(select, { target: { value: "WF-1" } }); + await openWorkflowDropdown(); + fireEvent.click(screen.getByTestId("task-workflow-option-WF-1")); expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1"); }); @@ -1194,14 +1241,26 @@ describe("TaskForm workflow picker (U6/R3)", () => { const onWorkflowIdChange = vi.fn(); renderTaskForm({ onWorkflowIdChange, selectedWorkflowId: "WF-1" }); - await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); - }); - const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; - fireEvent.change(select, { target: { value: "__none__" } }); + await openWorkflowDropdown(); + fireEvent.click(screen.getByTestId("task-workflow-option-none")); expect(onWorkflowIdChange).toHaveBeenCalledWith(null); }); + it("closes the dropdown on Escape and outside click", async () => { + await mockWorkflows([{ id: "WF-1", name: "QA" }]); + renderTaskForm({ onWorkflowIdChange: vi.fn() }); + + const trigger = await screen.findByTestId("task-workflow-dropdown-trigger"); + fireEvent.click(trigger); + expect(screen.getByTestId("task-workflow-dropdown-menu")).toBeTruthy(); + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(screen.queryByTestId("task-workflow-dropdown-menu")).toBeNull(); + fireEvent.click(trigger); + expect(screen.getByTestId("task-workflow-dropdown-menu")).toBeTruthy(); + fireEvent.mouseDown(document.body); + expect(screen.queryByTestId("task-workflow-dropdown-menu")).toBeNull(); + }); + it("shows a loading placeholder while workflows load", async () => { const { fetchWorkflows } = await import("../../api"); let resolveFn: (v: unknown) => void = () => {}; @@ -1215,14 +1274,11 @@ describe("TaskForm workflow picker (U6/R3)", () => { expect(screen.getByTestId("task-workflow-loading")).toBeTruthy(); resolveFn([{ id: "WF-1", name: "QA" }]); - // After the promise resolves, the loading placeholder is replaced by the - // populated select containing the fetched workflow option. await waitFor(() => { expect(screen.queryByTestId("task-workflow-loading")).toBeNull(); }); - const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; - const optionValues = Array.from(select.options).map((o) => o.value); - expect(optionValues).toContain("WF-1"); + await openWorkflowDropdown(); + expect(screen.getByTestId("task-workflow-option-WF-1")).toBeTruthy(); }); it.each([ @@ -1235,7 +1291,7 @@ describe("TaskForm workflow picker (U6/R3)", () => { renderTaskForm({ onWorkflowIdChange: vi.fn(), ...modeProps }); await waitFor(() => { - expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy(); }); // The old per-step checkbox UI and execution-order controls are gone on // every TaskForm surface (create and edit).