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).
|
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.
|
The workflow editor opens as a full-screen modal editor for inspecting built-ins and authoring custom workflows.
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
|
|||||||
@@ -438,6 +438,81 @@ The GitHub reference picker is a compact prompt-seeding helper inside the primar
|
|||||||
gap: var(--space-sm);
|
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 {
|
.workflow-steps-description {
|
||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
display: block;
|
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 { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2, Paperclip, Flag, Zap, Brain, Server } from "lucide-react";
|
||||||
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
import { WorkflowIcon } from "./WorkflowIcon";
|
||||||
|
|
||||||
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
|
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
|
||||||
if (status === "online") return t("taskForm.nodeStatusOnline", "Online");
|
if (status === "online") return t("taskForm.nodeStatusOnline", "Online");
|
||||||
@@ -253,6 +254,7 @@ export function TaskForm({
|
|||||||
(githubRepoOverride || "") !== "";
|
(githubRepoOverride || "") !== "";
|
||||||
|
|
||||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||||
|
const [showWorkflowDropdown, setShowWorkflowDropdown] = useState(false);
|
||||||
const executionModeRef = useRef(executionMode);
|
const executionModeRef = useRef(executionMode);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
executionModeRef.current = executionMode;
|
executionModeRef.current = executionMode;
|
||||||
@@ -285,6 +287,7 @@ export function TaskForm({
|
|||||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const workflowDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -487,6 +490,7 @@ export function TaskForm({
|
|||||||
if (moreOptionsOpen) return;
|
if (moreOptionsOpen) return;
|
||||||
setShowDepDropdown(false);
|
setShowDepDropdown(false);
|
||||||
setDepSearch("");
|
setDepSearch("");
|
||||||
|
setShowWorkflowDropdown(false);
|
||||||
}, [moreOptionsOpen]);
|
}, [moreOptionsOpen]);
|
||||||
|
|
||||||
// Auto-select title input text in edit mode (focus is handled by autoFocus)
|
// 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);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showDepDropdown]);
|
}, [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
|
// Exit description fullscreen mode when edit controls are unavailable
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "edit" || disabled) {
|
if (mode !== "edit" || disabled) {
|
||||||
@@ -735,12 +750,31 @@ export function TaskForm({
|
|||||||
|
|
||||||
// U6/R3: the project default workflow id (preselected + "(default)" badged).
|
// U6/R3: the project default workflow id (preselected + "(default)" badged).
|
||||||
const defaultWorkflowId = settings?.defaultWorkflowId ?? null;
|
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
|
const selectedWorkflow = selectedWorkflowId === null
|
||||||
? 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
|
const workflowInlineLabel = selectedWorkflowId === null
|
||||||
? t("taskForm.workflowNone", "No workflow")
|
? 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 selectedNode = (nodeOptions ?? []).find((node) => node.id === nodeId);
|
||||||
const nodeInlineLabel = selectedNode?.name ?? t("taskForm.nodeInlineDefault", "Node");
|
const nodeInlineLabel = selectedNode?.name ?? t("taskForm.nodeInlineDefault", "Node");
|
||||||
const modelInlineLabel = selectedPreset?.name ?? (presetMode === "custom" ? t("taskForm.modelsCustom", "Models") : t("taskForm.modelsDefault", "Models"));
|
const modelInlineLabel = selectedPreset?.name ?? (presetMode === "custom" ? t("taskForm.modelsCustom", "Models") : t("taskForm.modelsDefault", "Models"));
|
||||||
@@ -1014,13 +1048,19 @@ export function TaskForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm"
|
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}
|
disabled={disabled}
|
||||||
data-testid="task-form-inline-workflow"
|
data-testid="task-form-inline-workflow"
|
||||||
aria-label={t("taskForm.workflowInlineAria", "Choose workflow: {{workflow}}", { workflow: workflowInlineLabel })}
|
aria-label={t("taskForm.workflowInlineAria", "Choose workflow: {{workflow}}", { workflow: workflowInlineLabel })}
|
||||||
title={t("taskForm.workflowLabel", "Workflow")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1582,7 +1622,7 @@ export function TaskForm({
|
|||||||
selection is materialized atomically server-side via `workflowId`. */}
|
selection is materialized atomically server-side via `workflowId`. */}
|
||||||
{onWorkflowIdChange && (
|
{onWorkflowIdChange && (
|
||||||
<div className="form-group" data-testid="workflow-steps-section">
|
<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 ? (
|
{workflowsLoading ? (
|
||||||
<div className="workflow-select-loading" data-testid="task-workflow-loading">
|
<div className="workflow-select-loading" data-testid="task-workflow-loading">
|
||||||
<LoadingSpinner label={t("taskForm.workflowsLoading", "Loading workflows…")} />
|
<LoadingSpinner label={t("taskForm.workflowsLoading", "Loading workflows…")} />
|
||||||
@@ -1595,40 +1635,90 @@ export function TaskForm({
|
|||||||
{t("taskForm.workflowsCta", "Set up workflows in the editor")}
|
{t("taskForm.workflowsCta", "Set up workflows in the editor")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<div className="task-workflow-dropdown-wrap" ref={workflowDropdownRef}>
|
||||||
id="task-workflow-select"
|
{/*
|
||||||
data-testid="task-workflow-select"
|
FNXC:NewTaskWorkflowDropdown 2026-06-30-18:31:
|
||||||
value={
|
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.
|
||||||
selectedWorkflowId === null
|
*/}
|
||||||
? "__none__"
|
<button
|
||||||
: selectedWorkflowId === undefined
|
id="task-workflow-dropdown-trigger"
|
||||||
// Inherit project default: show the default option preselected,
|
type="button"
|
||||||
// or "No workflow" when no project default is configured.
|
className="btn dep-trigger task-workflow-dropdown-trigger"
|
||||||
? (defaultWorkflowId ?? "__none__")
|
data-testid="task-workflow-dropdown-trigger"
|
||||||
: selectedWorkflowId
|
aria-haspopup="listbox"
|
||||||
}
|
aria-expanded={showWorkflowDropdown}
|
||||||
disabled={disabled}
|
aria-label={t("taskForm.workflowInlineAria", "Choose workflow: {{workflow}}", { workflow: workflowInlineLabel })}
|
||||||
onChange={(e) => {
|
disabled={disabled}
|
||||||
const next = e.target.value;
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onWorkflowIdChange(next === "__none__" ? null : next);
|
onClick={() => setShowWorkflowDropdown((open) => !open)}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === "Escape") {
|
||||||
{/* "No workflow" listed FIRST (maps to null → explicit opt-out). */}
|
e.preventDefault();
|
||||||
<option value="__none__">{t("taskForm.workflowNone", "No workflow")}</option>
|
setShowWorkflowDropdown(false);
|
||||||
{/* 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)")}`}
|
{selectedWorkflow ? (
|
||||||
</option>
|
<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
|
</div>
|
||||||
.filter((w) => w.id !== defaultWorkflowId)
|
|
||||||
.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
<small className="workflow-select-help" data-testid="task-workflow-help">
|
<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.")}
|
{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>> = {}) {
|
function renderNewTaskModal(props: Partial<ComponentProps<typeof NewTaskModal>> = {}) {
|
||||||
const defaultProps: ComponentProps<typeof NewTaskModal> = {
|
const defaultProps: ComponentProps<typeof NewTaskModal> = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -579,7 +593,8 @@ describe("NewTaskModal", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTestId("task-form-inline-workflow"));
|
fireEvent.click(screen.getByTestId("task-form-inline-workflow"));
|
||||||
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
|
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"));
|
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||||
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
|
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
|
||||||
@@ -816,7 +831,7 @@ describe("NewTaskModal", () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), {
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), {
|
||||||
target: { value: "Verify the login page" },
|
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");
|
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
|
||||||
expect(trigger).toHaveTextContent("Steps: none");
|
expect(trigger).toHaveTextContent("Steps: none");
|
||||||
@@ -837,7 +852,7 @@ describe("NewTaskModal", () => {
|
|||||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
||||||
|
|
||||||
renderNewTaskModal();
|
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");
|
await screen.findByTestId("task-form-inline-optional-steps");
|
||||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||||
@@ -853,7 +868,7 @@ describe("NewTaskModal", () => {
|
|||||||
|
|
||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
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");
|
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
|
||||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||||
@@ -873,7 +888,7 @@ describe("NewTaskModal", () => {
|
|||||||
|
|
||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast before metadata" } });
|
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));
|
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-x", undefined));
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
|
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
|
||||||
@@ -893,7 +908,7 @@ describe("NewTaskModal", () => {
|
|||||||
|
|
||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task" } });
|
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");
|
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
|
||||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||||
|
|
||||||
@@ -910,7 +925,7 @@ describe("NewTaskModal", () => {
|
|||||||
vi.mocked(props.onCreateTask).mockClear();
|
vi.mocked(props.onCreateTask).mockClear();
|
||||||
vi.mocked(props.onCreateTask).mockResolvedValue(makeTask("FN-002"));
|
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(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");
|
const nextTrigger = await screen.findByTestId("task-form-inline-optional-steps");
|
||||||
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
|
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
|
||||||
fireEvent.click(nextTrigger);
|
fireEvent.click(nextTrigger);
|
||||||
@@ -932,7 +947,7 @@ describe("NewTaskModal", () => {
|
|||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
||||||
// "No workflow" → null selection → no optional-steps fetch, no dropdown.
|
// "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();
|
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||||
@@ -1427,7 +1442,7 @@ describe("NewTaskModal", () => {
|
|||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
await waitFor(() => {
|
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" } });
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit default" } });
|
||||||
@@ -1445,10 +1460,10 @@ describe("NewTaskModal", () => {
|
|||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
await waitFor(() => {
|
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.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Pick a workflow" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||||
|
|
||||||
@@ -1464,12 +1479,12 @@ describe("NewTaskModal", () => {
|
|||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
await waitFor(() => {
|
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.
|
// Pick a workflow, then switch to "No workflow" to register an explicit null.
|
||||||
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
|
await chooseWorkflowOption("WF-1");
|
||||||
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } });
|
await chooseWorkflowOption("__none__");
|
||||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow task" } });
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow task" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||||
|
|
||||||
@@ -1485,7 +1500,7 @@ describe("NewTaskModal", () => {
|
|||||||
renderNewTaskModal();
|
renderNewTaskModal();
|
||||||
|
|
||||||
await waitFor(() => {
|
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(screen.queryByTestId("workflow-step-order")).toBeNull();
|
||||||
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
|
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
|
||||||
@@ -1902,6 +1917,22 @@ describe("NewTaskModal", () => {
|
|||||||
FNXC:NewTask 2026-06-22-20:30:
|
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.
|
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", () => {
|
describe("desktop floating window", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockViewportMode = "desktop";
|
mockViewportMode = "desktop";
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> =
|
|||||||
return { ...result, props: mergedProps };
|
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>> = {}) {
|
function renderTaskFormWithDescriptionState(props: Partial<React.ComponentProps<typeof TaskForm>> = {}) {
|
||||||
const defaultProps: React.ComponentProps<typeof TaskForm> = {
|
const defaultProps: React.ComponentProps<typeof TaskForm> = {
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
@@ -763,13 +769,11 @@ describe("TaskForm", () => {
|
|||||||
const onWorkflowIdChange = vi.fn();
|
const onWorkflowIdChange = vi.fn();
|
||||||
renderTaskForm({ onWorkflowIdChange });
|
renderTaskForm({ onWorkflowIdChange });
|
||||||
|
|
||||||
await waitFor(() => {
|
await openWorkflowDropdown();
|
||||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
fireEvent.click(screen.getByTestId("task-workflow-option-WF-1"));
|
||||||
fireEvent.change(select, { target: { value: "WF-1" } });
|
|
||||||
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
|
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
|
||||||
|
expect(screen.queryByTestId("task-workflow-select")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables all inputs when disabled prop is true", () => {
|
it("disables all inputs when disabled prop is true", () => {
|
||||||
@@ -1111,12 +1115,13 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
vi.clearAllMocks();
|
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");
|
const { fetchWorkflows } = await import("../../api");
|
||||||
vi.mocked(fetchWorkflows).mockResolvedValueOnce(
|
vi.mocked(fetchWorkflows).mockResolvedValueOnce(
|
||||||
defs.map((d) => ({
|
defs.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
name: d.name,
|
name: d.name,
|
||||||
|
icon: d.icon,
|
||||||
description: "",
|
description: "",
|
||||||
kind: d.kind ?? "workflow",
|
kind: d.kind ?? "workflow",
|
||||||
ir: { version: "v1", name: d.name, nodes: [], edges: [] },
|
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" }]);
|
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||||
|
|
||||||
await waitFor(() => {
|
const menu = await openWorkflowDropdown();
|
||||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
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");
|
||||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
|
||||||
expect(select.options[0].textContent).toBe("No workflow");
|
|
||||||
expect(screen.getByTestId("task-workflow-help")).toBeTruthy();
|
expect(screen.getByTestId("task-workflow-help")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("task-workflow-select")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("badges the project default workflow with (default)", async () => {
|
it("badges the project default workflow with (default)", async () => {
|
||||||
@@ -1155,9 +1159,59 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
it("excludes fragments from the dropdown", async () => {
|
||||||
@@ -1167,13 +1221,9 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
]);
|
]);
|
||||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||||
|
|
||||||
await waitFor(() => {
|
await openWorkflowDropdown();
|
||||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
expect(screen.getByText("QA")).toBeTruthy();
|
||||||
});
|
expect(screen.queryByText("Doc Fragment")).toBeNull();
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the chosen workflow id via onWorkflowIdChange", async () => {
|
it("passes the chosen workflow id via onWorkflowIdChange", async () => {
|
||||||
@@ -1181,11 +1231,8 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
const onWorkflowIdChange = vi.fn();
|
const onWorkflowIdChange = vi.fn();
|
||||||
renderTaskForm({ onWorkflowIdChange });
|
renderTaskForm({ onWorkflowIdChange });
|
||||||
|
|
||||||
await waitFor(() => {
|
await openWorkflowDropdown();
|
||||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
fireEvent.click(screen.getByTestId("task-workflow-option-WF-1"));
|
||||||
});
|
|
||||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
|
||||||
fireEvent.change(select, { target: { value: "WF-1" } });
|
|
||||||
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
|
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1194,14 +1241,26 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
const onWorkflowIdChange = vi.fn();
|
const onWorkflowIdChange = vi.fn();
|
||||||
renderTaskForm({ onWorkflowIdChange, selectedWorkflowId: "WF-1" });
|
renderTaskForm({ onWorkflowIdChange, selectedWorkflowId: "WF-1" });
|
||||||
|
|
||||||
await waitFor(() => {
|
await openWorkflowDropdown();
|
||||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
fireEvent.click(screen.getByTestId("task-workflow-option-none"));
|
||||||
});
|
|
||||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
|
||||||
fireEvent.change(select, { target: { value: "__none__" } });
|
|
||||||
expect(onWorkflowIdChange).toHaveBeenCalledWith(null);
|
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 () => {
|
it("shows a loading placeholder while workflows load", async () => {
|
||||||
const { fetchWorkflows } = await import("../../api");
|
const { fetchWorkflows } = await import("../../api");
|
||||||
let resolveFn: (v: unknown) => void = () => {};
|
let resolveFn: (v: unknown) => void = () => {};
|
||||||
@@ -1215,14 +1274,11 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
expect(screen.getByTestId("task-workflow-loading")).toBeTruthy();
|
expect(screen.getByTestId("task-workflow-loading")).toBeTruthy();
|
||||||
resolveFn([{ id: "WF-1", name: "QA" }]);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("task-workflow-loading")).toBeNull();
|
expect(screen.queryByTestId("task-workflow-loading")).toBeNull();
|
||||||
});
|
});
|
||||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
await openWorkflowDropdown();
|
||||||
const optionValues = Array.from(select.options).map((o) => o.value);
|
expect(screen.getByTestId("task-workflow-option-WF-1")).toBeTruthy();
|
||||||
expect(optionValues).toContain("WF-1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -1235,7 +1291,7 @@ describe("TaskForm workflow picker (U6/R3)", () => {
|
|||||||
renderTaskForm({ onWorkflowIdChange: vi.fn(), ...modeProps });
|
renderTaskForm({ onWorkflowIdChange: vi.fn(), ...modeProps });
|
||||||
|
|
||||||
await waitFor(() => {
|
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
|
// The old per-step checkbox UI and execution-order controls are gone on
|
||||||
// every TaskForm surface (create and edit).
|
// every TaskForm surface (create and edit).
|
||||||
|
|||||||
Reference in New Issue
Block a user