FN-7301: add icon workflow picker to new task form
Add an icon-capable New Task workflow picker that preserves create-time workflow selection semantics. - Replace the native workflow select in TaskForm with a styled dropdown showing workflow icons, default badges, duplicate-name IDs, and explicit No workflow opt-out. - Wire the inline workflow chip to reveal and open the new picker while keeping workflowId payload handling unchanged. - Add dropdown layout styles, dashboard docs, release notes, and regression coverage for modal create flows and TaskForm selection behavior. Files changed: .changeset/fn-7301-new-task-workflow-dropdown.md | 7 + docs/dashboard-guide.md | 3 + packages/dashboard/app/components/NewTaskModal.css | 75 ++++++++++ packages/dashboard/app/components/TaskForm.tsx | 166 ++++++++++++++++----- .../app/components/__tests__/NewTaskModal.test.tsx | 61 ++++++-- .../app/components/__tests__/TaskForm.test.tsx | 128 +++++++++++----- 6 files changed, 351 insertions(+), 89 deletions(-) Fusion-Task-Id: FN-7301 Fusion-Task-Lineage: f3d534a2-48bd-416a-88a6-f85410ee4f85 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7301-new-task-workflow-dropdown.md
Normal file
7
.changeset/fn-7301-new-task-workflow-dropdown.md
Normal file
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
<!-- FNXC:NewTaskWorkflowDropdown 2026-06-30-18:52: The full New Task dialog workflow picker now matches the icon-rich workflow identity used in Board/List selectors while preserving create-time workflowId semantics. -->
|
||||
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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const workflowDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<string, number>());
|
||||
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({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => revealAdvancedControl("#task-workflow-select, [data-testid='task-workflow-cta']")}
|
||||
onClick={() => {
|
||||
revealAdvancedControl("#task-workflow-dropdown-trigger, [data-testid='task-workflow-cta']");
|
||||
window.setTimeout(() => setShowWorkflowDropdown(true), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
data-testid="task-form-inline-workflow"
|
||||
aria-label={t("taskForm.workflowInlineAria", "Choose workflow: {{workflow}}", { workflow: workflowInlineLabel })}
|
||||
title={t("taskForm.workflowLabel", "Workflow")}
|
||||
>
|
||||
{workflowInlineLabel}
|
||||
{selectedWorkflow ? (
|
||||
<WorkflowIcon workflowId={selectedWorkflow.id} icon={selectedWorkflow.icon} className="task-workflow-trigger-icon" decorative />
|
||||
) : null}
|
||||
<span className="task-form-inline-workflow-label">{workflowInlineLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1582,7 +1622,7 @@ export function TaskForm({
|
||||
selection is materialized atomically server-side via `workflowId`. */}
|
||||
{onWorkflowIdChange && (
|
||||
<div className="form-group" data-testid="workflow-steps-section">
|
||||
<label htmlFor="task-workflow-select">{t("taskForm.workflowLabel", "Workflow")}</label>
|
||||
<label htmlFor="task-workflow-dropdown-trigger">{t("taskForm.workflowLabel", "Workflow")}</label>
|
||||
{workflowsLoading ? (
|
||||
<div className="workflow-select-loading" data-testid="task-workflow-loading">
|
||||
<LoadingSpinner label={t("taskForm.workflowsLoading", "Loading workflows…")} />
|
||||
@@ -1595,40 +1635,90 @@ export function TaskForm({
|
||||
{t("taskForm.workflowsCta", "Set up workflows in the editor")}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="task-workflow-select"
|
||||
data-testid="task-workflow-select"
|
||||
value={
|
||||
selectedWorkflowId === null
|
||||
? "__none__"
|
||||
: selectedWorkflowId === undefined
|
||||
// Inherit project default: show the default option preselected,
|
||||
// or "No workflow" when no project default is configured.
|
||||
? (defaultWorkflowId ?? "__none__")
|
||||
: selectedWorkflowId
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
onWorkflowIdChange(next === "__none__" ? null : next);
|
||||
}}
|
||||
>
|
||||
{/* "No workflow" listed FIRST (maps to null → explicit opt-out). */}
|
||||
<option value="__none__">{t("taskForm.workflowNone", "No workflow")}</option>
|
||||
{/* Project default preselected + badged when configured. */}
|
||||
{defaultWorkflowId && workflows.some((w) => w.id === defaultWorkflowId) && (
|
||||
<option value={defaultWorkflowId}>
|
||||
{`${workflows.find((w) => w.id === defaultWorkflowId)?.name ?? defaultWorkflowId} ${t("taskForm.workflowDefaultBadge", "(default)")}`}
|
||||
</option>
|
||||
<div className="task-workflow-dropdown-wrap" ref={workflowDropdownRef}>
|
||||
{/*
|
||||
FNXC:NewTaskWorkflowDropdown 2026-06-30-18:31:
|
||||
Native selects cannot render the shared workflow identity icons. The create-time workflow selector uses a styled button/listbox while keeping the atomic `workflowId` contract: `__none__` becomes null, undefined still displays inherited default state, and real workflow ids are passed through unchanged.
|
||||
*/}
|
||||
<button
|
||||
id="task-workflow-dropdown-trigger"
|
||||
type="button"
|
||||
className="btn dep-trigger task-workflow-dropdown-trigger"
|
||||
data-testid="task-workflow-dropdown-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showWorkflowDropdown}
|
||||
aria-label={t("taskForm.workflowInlineAria", "Choose workflow: {{workflow}}", { workflow: workflowInlineLabel })}
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowWorkflowDropdown((open) => !open)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowWorkflowDropdown(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedWorkflow ? (
|
||||
<WorkflowIcon workflowId={selectedWorkflow.id} icon={selectedWorkflow.icon} className="task-workflow-trigger-icon" decorative />
|
||||
) : null}
|
||||
<span className="task-workflow-trigger-label">{workflowInlineLabel}</span>
|
||||
{selectedWorkflowId === undefined && defaultWorkflow ? (
|
||||
<span className="task-workflow-default-badge">{t("taskForm.workflowDefaultBadge", "(default)")}</span>
|
||||
) : null}
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{showWorkflowDropdown && (
|
||||
<div className="dep-dropdown task-workflow-dropdown-menu" role="listbox" data-testid="task-workflow-dropdown-menu">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedWorkflowValue === "__none__"}
|
||||
className={`dep-dropdown-item task-workflow-dropdown-option${selectedWorkflowValue === "__none__" ? " selected" : ""}`}
|
||||
data-testid="task-workflow-option-none"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onWorkflowIdChange(null);
|
||||
setShowWorkflowDropdown(false);
|
||||
}}
|
||||
>
|
||||
<span className="task-workflow-option-copy">
|
||||
<span className="dep-dropdown-title task-workflow-option-name">{t("taskForm.workflowNone", "No workflow")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{orderedWorkflowOptions.map((workflow) => {
|
||||
const optionLabel = workflowOptionLabel(workflow);
|
||||
const isSelected = selectedWorkflowValue === workflow.id;
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-label={workflow.id === defaultWorkflowId ? `${optionLabel} ${t("taskForm.workflowDefaultBadge", "(default)")}` : optionLabel}
|
||||
className={`dep-dropdown-item task-workflow-dropdown-option${isSelected ? " selected" : ""}`}
|
||||
data-testid={`task-workflow-option-${workflow.id}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onWorkflowIdChange(workflow.id);
|
||||
setShowWorkflowDropdown(false);
|
||||
}}
|
||||
>
|
||||
<WorkflowIcon workflowId={workflow.id} icon={workflow.icon} className="task-workflow-option-icon" decorative />
|
||||
<span className="task-workflow-option-copy">
|
||||
<span className="dep-dropdown-title task-workflow-option-name">{workflow.name}</span>
|
||||
{(workflowNameCounts.get(workflow.name) ?? 0) > 1 ? (
|
||||
<span className="dep-dropdown-subtitle task-workflow-option-id">{workflow.id}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{workflow.id === defaultWorkflowId ? (
|
||||
<span className="task-workflow-default-badge">{t("taskForm.workflowDefaultBadge", "(default)")}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{workflows
|
||||
.filter((w) => w.id !== defaultWorkflowId)
|
||||
.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<small className="workflow-select-help" data-testid="task-workflow-help">
|
||||
{t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")}
|
||||
|
||||
@@ -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<ComponentProps<typeof NewTaskModal>> = {}) {
|
||||
const defaultProps: ComponentProps<typeof NewTaskModal> = {
|
||||
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";
|
||||
|
||||
@@ -87,6 +87,12 @@ function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> =
|
||||
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<React.ComponentProps<typeof TaskForm>> = {}) {
|
||||
const defaultProps: React.ComponentProps<typeof TaskForm> = {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user