From f08bf6af2edf5518b4a887fc096e17cc599c249f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 00:22:34 -0700 Subject: [PATCH] feat(dashboard): shared optional-steps dropdown in the quick-add card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WorkflowOptionalStepsDropdown — a controlled, portal-rendered multi-select (listbox a11y, keyboard nav, committed 'Steps: N selected' label matrix, render-nothing empty state) shared by the quick-add card and (next) the full modal. Swap InlineCreateCard's inline chip toggles for it; the fetch/seed/submit wiring is unchanged. Remove the now-unused chip CSS. --- .../app/components/InlineCreateCard.css | 9 - .../app/components/InlineCreateCard.tsx | 28 +-- .../WorkflowOptionalStepsDropdown.css | 80 +++++++ .../WorkflowOptionalStepsDropdown.tsx | 203 ++++++++++++++++++ .../__tests__/InlineCreateCard.test.tsx | 14 +- .../WorkflowOptionalStepsDropdown.test.tsx | 107 +++++++++ 6 files changed, 405 insertions(+), 36 deletions(-) create mode 100644 packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css create mode 100644 packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx diff --git a/packages/dashboard/app/components/InlineCreateCard.css b/packages/dashboard/app/components/InlineCreateCard.css index 6d361c59f0..c739f8aa67 100644 --- a/packages/dashboard/app/components/InlineCreateCard.css +++ b/packages/dashboard/app/components/InlineCreateCard.css @@ -160,11 +160,6 @@ gap: var(--space-xs); } -.inline-create-optional-step[aria-pressed="true"] { - border-color: var(--accent); - color: var(--accent); -} - .inline-create-hint { font-size: 11px; color: var(--text-dim); @@ -318,10 +313,6 @@ width: 100%; } - .inline-create-optional-step { - flex: 1 1 auto; - } - .inline-create-priority-select { min-height: 36px; } diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 1376695c8d..85f0ae4269 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -15,6 +15,7 @@ import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { applyPresetToSelection } from "../utils/modelPresets"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { WorkflowSelector } from "./WorkflowSelector"; +import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const STORAGE_KEY = "kb-inline-create-text"; @@ -1082,27 +1083,12 @@ export function InlineCreateCard({ className="inline-create-optional-steps" aria-label={t("inline.optionalWorkflowSteps", "Optional workflow steps")} > - {optionalSteps.map((step) => { - const enabled = enabledOptionalStepIds.includes(step.templateId); - const testId = step.templateId === "browser-verification" - ? "inline-create-browser-verification-toggle" - : `inline-create-optional-step-${step.templateId}`; - return ( - - ); - })} + )} diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css new file mode 100644 index 0000000000..a733ddda74 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css @@ -0,0 +1,80 @@ +/* WorkflowOptionalStepsDropdown — shared optional-step multi-select. The panel + * renders through a portal (position: fixed) so it is not clipped inside a modal's + * overflow boundary. */ + +.wf-optional-steps-dropdown { + display: inline-flex; +} + +.wf-optional-steps-dropdown-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 4px 8px; + font-size: 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm, 6px); + background: var(--surface, transparent); + color: inherit; + cursor: pointer; +} + +.wf-optional-steps-dropdown-trigger:disabled { + opacity: 0.5; + cursor: default; +} + +.wf-optional-steps-dropdown-panel { + position: fixed; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 320px; + overflow-y: auto; + padding: 4px; + background: var(--surface, #fff); + border: 1px solid var(--border); + border-radius: var(--radius-sm, 6px); + box-shadow: var(--shadow-md, 0 6px 20px rgba(0, 0, 0, 0.18)); +} + +.wf-optional-steps-dropdown-option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm, 6px); + cursor: pointer; +} + +.wf-optional-steps-dropdown-option:hover, +.wf-optional-steps-dropdown-option.is-active { + background: var(--surface-hover, rgba(127, 127, 127, 0.12)); +} + +.wf-optional-steps-dropdown-option:focus-visible { + outline: 2px solid var(--accent, #4f7cff); + outline-offset: -2px; +} + +.wf-optional-steps-dropdown-option-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.wf-optional-steps-dropdown-option-name { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + font-weight: 600; +} + +.wf-optional-steps-dropdown-option-desc { + font-size: 0.72rem; + color: var(--text-muted); +} diff --git a/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx new file mode 100644 index 0000000000..effd5911c5 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx @@ -0,0 +1,203 @@ +/** + * WorkflowOptionalStepsDropdown — a controlled multi-select for a workflow's + * optional steps, shared by the quick-add card (U5) and the full New Task modal + * (U4) so both creation surfaces present the same interaction. + * + * Controlled: the parent owns the enabled set (`enabledIds`) and seeds it from + * each step's `defaultOn`; this component owns only open/close UI state. The panel + * renders through a portal so it is not clipped by a modal's overflow boundary. + * + * Empty state (committed): renders nothing when there are no optional steps — + * matching the quick-add card's prior no-chip-block behavior and the modal's + * empty-state choice, so both surfaces look identical. + * + * Accessibility: trigger has aria-haspopup/aria-expanded; the panel is a + * role="listbox" labelled by the trigger; each option is a role="option" with + * aria-checked. Escape closes and refocuses the trigger; arrow keys move the + * active option; outside-click closes. + */ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import type { ResolvedWorkflowOptionalStep } from "@fusion/core"; +import { phaseBadge } from "./workflow-phase-badge"; +import "./WorkflowOptionalStepsDropdown.css"; + +interface WorkflowOptionalStepsDropdownProps { + steps: ResolvedWorkflowOptionalStep[]; + enabledIds: string[]; + onToggle: (templateId: string) => void; + disabled?: boolean; + /** Test/styling hook applied to the trigger. */ + triggerTestId?: string; +} + +interface PanelPosition { + top: number; + left: number; + width: number; +} + +export function WorkflowOptionalStepsDropdown({ + steps, + enabledIds, + onToggle, + disabled = false, + triggerTestId = "wf-optional-steps-dropdown-trigger", +}: WorkflowOptionalStepsDropdownProps) { + const { t } = useTranslation("app"); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [position, setPosition] = useState(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const labelId = useId(); + + const reposition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + setPosition({ top: rect.bottom + 4, left: rect.left, width: rect.width }); + }, []); + + // Reposition on open and keep anchored during scroll/resize. + useEffect(() => { + if (!isOpen) return; + reposition(); + const handle = () => reposition(); + window.addEventListener("resize", handle); + window.addEventListener("scroll", handle, true); + return () => { + window.removeEventListener("resize", handle); + window.removeEventListener("scroll", handle, true); + }; + }, [isOpen, reposition]); + + // Outside-click closes (capture so it fires before the trigger's own handler). + useEffect(() => { + if (!isOpen) return; + const onDocMouseDown = (e: MouseEvent) => { + const target = e.target as Node; + if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; + setIsOpen(false); + }; + document.addEventListener("mousedown", onDocMouseDown); + return () => document.removeEventListener("mousedown", onDocMouseDown); + }, [isOpen]); + + const close = useCallback(() => { + setIsOpen(false); + triggerRef.current?.focus(); + }, []); + + // Empty state: render nothing (committed behavior, shared with the modal). + if (steps.length === 0) return null; + + const selectedCount = steps.filter((s) => enabledIds.includes(s.templateId)).length; + const triggerLabel = + selectedCount === 0 + ? t("workflowOptionalSteps.triggerNone", "Steps: none") + : t("workflowOptionalSteps.triggerCount", "Steps: {{count}} selected", { count: selectedCount }); + + const onTriggerKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsOpen(true); + setActiveIndex(0); + } + }; + + const onPanelKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, steps.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + const step = steps[activeIndex]; + if (step) onToggle(step.templateId); + } + }; + + return ( +
+ + + {isOpen && + position && + createPortal( +
+ {steps.map((step, i) => { + const checked = enabledIds.includes(step.templateId); + return ( +
{ + if (i === activeIndex && isOpen) el?.focus(); + }} + className={`wf-optional-steps-dropdown-option${i === activeIndex ? " is-active" : ""}`} + data-testid={`wf-optional-steps-dropdown-option-${step.templateId}`} + onClick={() => onToggle(step.templateId)} + > + +
+ + {step.name} + {phaseBadge(step.phase, step.templateId, "wf-optional-steps-dropdown-phase", t)} + + {step.description && ( + {step.description} + )} +
+
+ ); + })} +
, + document.body, + )} +
+ ); +} + +export default WorkflowOptionalStepsDropdown; diff --git a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx index d2d823997a..2696f3136f 100644 --- a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx @@ -1079,12 +1079,14 @@ describe("InlineCreateCard button visibility when collapsed", () => { target: { value: "Verify login flow in browser" }, }); - const toggle = await screen.findByTestId("inline-create-browser-verification-toggle"); - expect(toggle).toHaveTextContent("Browser Verification"); - expect(toggle).toHaveAttribute("aria-pressed", "false"); - - fireEvent.click(toggle); - expect(toggle).toHaveAttribute("aria-pressed", "true"); + // Open the optional-steps dropdown and select browser verification. + const trigger = await screen.findByTestId("inline-create-optional-steps-trigger"); + expect(trigger).toHaveTextContent("Steps: none"); + fireEvent.click(trigger); + const option = await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"); + expect(option).toHaveAttribute("aria-checked", "false"); + fireEvent.click(option); + expect(trigger).toHaveTextContent("Steps: 1 selected"); fireEvent.click(screen.getByTestId("save-button")); await waitFor(() => { diff --git a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx new file mode 100644 index 0000000000..4d458345b2 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; +import { useState } from "react"; +import type { ResolvedWorkflowOptionalStep } from "@fusion/core"; +import { WorkflowOptionalStepsDropdown } from "../WorkflowOptionalStepsDropdown"; + +const STEP: ResolvedWorkflowOptionalStep = { + templateId: "browser-verification", + name: "Browser Verification", + description: "Verify web application functionality using browser automation", + icon: "globe", + phase: "pre-merge", + defaultOn: false, +}; + +// Controlled host: parent owns the enabled set, mirroring the create surfaces. +function Host({ steps, initial = [] }: { steps: ResolvedWorkflowOptionalStep[]; initial?: string[] }) { + const [enabled, setEnabled] = useState(initial); + return ( + + setEnabled((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + } + /> + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("WorkflowOptionalStepsDropdown", () => { + it("renders nothing when there are no optional steps", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("reflects the selected count in the trigger label", () => { + render(); + const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger"); + expect(trigger).toHaveTextContent("Steps: none"); + }); + + it("opens, toggles a step, and updates the trigger count", () => { + render(); + const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + const option = screen.getByTestId("wf-optional-steps-dropdown-option-browser-verification"); + expect(option).toHaveAttribute("role", "option"); + expect(option).toHaveAttribute("aria-checked", "false"); + fireEvent.click(option); + expect(screen.getByTestId("wf-optional-steps-dropdown-option-browser-verification")).toHaveAttribute( + "aria-checked", + "true", + ); + expect(trigger).toHaveTextContent("Steps: 1 selected"); + }); + + it("pre-checks a step seeded as enabled by the parent (defaultOn)", () => { + render(); + fireEvent.click(screen.getByTestId("wf-optional-steps-dropdown-trigger")); + expect(screen.getByTestId("wf-optional-steps-dropdown-option-browser-verification")).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("exposes the panel as an accessible listbox labelled by the trigger", () => { + render(); + fireEvent.click(screen.getByTestId("wf-optional-steps-dropdown-trigger")); + const panel = screen.getByTestId("wf-optional-steps-dropdown-panel"); + expect(panel).toHaveAttribute("role", "listbox"); + expect(within(panel).getByText("Browser Verification")).toBeTruthy(); + }); + + it("closes on Escape", () => { + render(); + const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger"); + fireEvent.click(trigger); + const panel = screen.getByTestId("wf-optional-steps-dropdown-panel"); + fireEvent.keyDown(panel, { key: "Escape" }); + expect(screen.queryByTestId("wf-optional-steps-dropdown-panel")).toBeNull(); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("closes on outside click without losing selection", () => { + render( +
+ + +
, + ); + const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger"); + fireEvent.click(trigger); + expect(screen.getByTestId("wf-optional-steps-dropdown-panel")).toBeTruthy(); + fireEvent.mouseDown(screen.getByTestId("outside")); + expect(screen.queryByTestId("wf-optional-steps-dropdown-panel")).toBeNull(); + // Selection preserved. + expect(trigger).toHaveTextContent("Steps: 1 selected"); + }); +});