feat(dashboard): shared optional-steps dropdown in the quick-add card

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.
This commit is contained in:
gsxdsm
2026-06-21 00:22:34 -07:00
parent feb9ffd383
commit f08bf6af2e
6 changed files with 405 additions and 36 deletions

View File

@@ -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;
}

View File

@@ -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 (
<button
key={step.templateId}
type="button"
className="btn btn-sm inline-create-optional-step"
data-testid={testId}
aria-pressed={enabled}
onClick={() => toggleOptionalStep(step.templateId)}
title={t("inline.toggleOptionalWorkflowStep", "Toggle optional workflow step: {{name}}", { name: step.name })}
>
{enabled
? t("inline.optionalWorkflowStepChecked", "{{name}} ✓", { name: step.name })
: step.name}
</button>
);
})}
<WorkflowOptionalStepsDropdown
steps={optionalSteps}
enabledIds={enabledOptionalStepIds}
onToggle={toggleOptionalStep}
triggerTestId="inline-create-optional-steps-trigger"
/>
</div>
)}

View File

@@ -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);
}

View File

@@ -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<PanelPosition | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div className="wf-optional-steps-dropdown">
<button
ref={triggerRef}
type="button"
id={labelId}
className="wf-optional-steps-dropdown-trigger"
data-testid={triggerTestId}
aria-haspopup="listbox"
aria-expanded={isOpen}
disabled={disabled}
onClick={() => {
setIsOpen((o) => !o);
setActiveIndex(0);
}}
onKeyDown={onTriggerKeyDown}
>
<span>{triggerLabel}</span>
<ChevronDown size={13} aria-hidden />
</button>
{isOpen &&
position &&
createPortal(
<div
ref={panelRef}
className="wf-optional-steps-dropdown-panel"
role="listbox"
aria-label={t("workflowOptionalSteps.title", "Optional steps")}
aria-labelledby={labelId}
data-testid="wf-optional-steps-dropdown-panel"
style={{ top: position.top, left: position.left, minWidth: position.width }}
onKeyDown={onPanelKeyDown}
>
{steps.map((step, i) => {
const checked = enabledIds.includes(step.templateId);
return (
<div
key={step.templateId}
role="option"
aria-checked={checked}
tabIndex={i === activeIndex ? 0 : -1}
ref={(el) => {
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)}
>
<input
type="checkbox"
checked={checked}
tabIndex={-1}
readOnly
aria-hidden
/>
<div className="wf-optional-steps-dropdown-option-body">
<span className="wf-optional-steps-dropdown-option-name">
{step.name}
{phaseBadge(step.phase, step.templateId, "wf-optional-steps-dropdown-phase", t)}
</span>
{step.description && (
<span className="wf-optional-steps-dropdown-option-desc">{step.description}</span>
)}
</div>
</div>
);
})}
</div>,
document.body,
)}
</div>
);
}
export default WorkflowOptionalStepsDropdown;

View File

@@ -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(() => {

View File

@@ -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<string[]>(initial);
return (
<WorkflowOptionalStepsDropdown
steps={steps}
enabledIds={enabled}
onToggle={(id) =>
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(<Host steps={[]} />);
expect(container.firstChild).toBeNull();
});
it("reflects the selected count in the trigger label", () => {
render(<Host steps={[STEP]} />);
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(<Host steps={[STEP]} />);
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(<Host steps={[STEP]} initial={["browser-verification"]} />);
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(<Host steps={[STEP]} />);
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(<Host steps={[STEP]} />);
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(
<div>
<Host steps={[STEP]} initial={["browser-verification"]} />
<button data-testid="outside">outside</button>
</div>,
);
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");
});
});