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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user