FN-6825: combine workflow actions in switcher

Move workflow edit and creation actions into the shared workflow switcher dropdown.

- Add inline edit buttons for workflow options and a persistent New workflow footer action.
- Remove separate board and list workflow toolbar icon buttons while preserving header portal behavior.
- Update workflow switcher styling, translations, docs, and regression coverage for board/list surfaces.

Files changed:
 docs/dashboard-guide.md                            |  8 +--
 packages/dashboard/app/App.tsx                     |  1 +
 packages/dashboard/app/components/Board.tsx        | 52 ++++----------
 packages/dashboard/app/components/Lane.css         |  4 --
 packages/dashboard/app/components/ListView.tsx     | 44 +++++-------
 .../dashboard/app/components/WorkflowSwitcher.css  | 60 +++++++++++++---
 .../dashboard/app/components/WorkflowSwitcher.tsx  | 80 +++++++++++++++++----
 .../app/components/__tests__/Board.test.tsx        | 62 +++++++++-------
 .../app/components/__tests__/ListView.test.tsx     | 27 +++++--
 .../components/__tests__/WorkflowSwitcher.test.tsx | 84 ++++++++++++++++++++++
 packages/i18n/locales/en/app.json                  |  4 +-
 packages/i18n/locales/es/app.json                  |  4 +-
 packages/i18n/locales/fr/app.json                  |  4 +-
 packages/i18n/locales/ko/app.json                  |  4 +-
 packages/i18n/locales/zh-CN/app.json               |  4 +-
 packages/i18n/locales/zh-TW/app.json               |  4 +-
 16 files changed, 314 insertions(+), 132 deletions(-)

Fusion-Task-Id: FN-6825
Fusion-Task-Lineage: 87754918-a695-4e4e-bba5-833e74f7f92f
This commit is contained in:
gsxdsm
2026-06-20 23:43:13 -07:00
parent a768d36dae
commit 0a04feec93
16 changed files with 313 additions and 131 deletions

View File

@@ -25,7 +25,7 @@ Enable **Left Sidebar Navigation** from **Settings → Experimental Features** t
When enabled on desktop or tablet project screens, the sidebar contains the primary destinations (Board, List, Agents, Command Center, Missions, Chat, Documents, Mailbox, and plugin primary views), Header overflow destinations as regular entries (Research, Insights, Skills, Memory, Secrets, Stash Recovery, Evals, Goals, Dev Server, Todos, and plugin overflow views when their flags/plugins are enabled), and a Settings button pinned to the bottom. The Header retains the Fusion brand and project selector, keeps its non-navigation controls, and hides the view-toggle row and **More views** trigger so there is only one canonical navigation surface.
While the sidebar is active on desktop/tablet project screens, Board and List workflow controls move into the Header slot that replaces the hidden view toggle. Board keeps its workflow selector plus edit/create workflow buttons; List keeps its workflow selector plus create workflow button. The standalone workflow row above the board/list content is removed in this mode. When the flag is off, outside project screens, or on mobile, workflow controls remain inline exactly as before.
While the sidebar is active on desktop/tablet project screens, Board and List workflow controls move into the Header slot that replaces the hidden view toggle. Board and List share one workflow dropdown: each workflow row includes an inline edit action, and a persistent **New workflow** action remains at the bottom of the dropdown while the workflow list scrolls. The standalone workflow row above the board/list content is removed in this mode. When the flag is off, outside project screens, or on mobile, workflow controls remain inline with the same consolidated dropdown.
A small right-border toggle collapses or expands the sidebar without consuming a navigation row; collapsed rail mode keeps accessible labels/titles preserved, and the expanded width can be resized from the right-edge separator. Collapsed state and expanded width are saved in browser `localStorage` (`fusion:left-sidebar-collapsed` and `fusion:left-sidebar-width`) and restored on reload.
@@ -79,7 +79,7 @@ Features:
- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) appear while the dropdown is expanded, including on each workflow option. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) appear while the dropdown is expanded, including on each workflow option. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash.
![Board view](./screenshots/dashboard-overview.png)
@@ -130,7 +130,7 @@ The workflow editor opens as a full-screen modal editor for inspecting built-ins
Navigation:
- Open a task or board surface that shows the workflow selector, then choose **Manage…**.
- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected. The board/list workflow dropdown also previews each workflow's Todo / In Progress / Done task counts inline before switching.
- From the Board or List workflow dropdown, use the inline edit button on a workflow row to open that workflow directly, or use the persistent **New workflow** footer to create a workflow without leaving the dropdown. The same dropdown previews each workflow's Todo / In Progress / Done task counts inline before switching.
- Use the global **Workflow** / **Workflows** entry point from desktop header, compact header overflow, or mobile **More** navigation to browse definitions.
- From Settings moved-setting stubs, choose **Open workflow settings** to jump to the default workflow's settings values.
@@ -141,7 +141,7 @@ Behavior:
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow.
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available.
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the Board/List workflow dropdown row edit action opens directly to the selected workflow editor when that selected workflow is available.
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. The structural **start** node opens an inspector for the workflow entry column when the workflow defines columns; the **Name** field remains unavailable because the start label is structural. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls.
- Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split.
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens

View File

@@ -1890,6 +1890,7 @@ function AppInner() {
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
settingsLoaded={settingsLoaded}

View File

@@ -7,8 +7,6 @@ import "./Board.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Pencil, Plus } from "lucide-react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
@@ -148,7 +146,6 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
const { t } = useTranslation("app");
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
@@ -570,46 +567,27 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
}
if (workflowMode && selectedWorkflow) {
const shouldRenderWorkflowControls = workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor;
const workflowToolbar = shouldRenderWorkflowControls ? (
const shouldRenderWorkflowControls = workflowOptions.length > 1 || Boolean(onCreateWorkflow || onOpenWorkflowEditor);
const workflowToolbar = shouldRenderWorkflowControls && workflowOptions.length > 0 ? (
<div className="board-workflow-toolbar">
{workflowOptions.length > 1 && (
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
/>
</div>
)}
{onOpenWorkflowEditor && (
<button
type="button"
className="btn btn-icon btn-sm board-workflow-edit-btn"
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
title={t("board.workflow.edit", "Edit workflows")}
aria-label={t("board.workflow.edit", "Edit workflows")}
>
<Pencil size={15} />
</button>
)}
{onCreateWorkflow && (
<button
type="button"
className="btn btn-icon btn-sm board-workflow-create-btn"
onClick={onCreateWorkflow}
title={t("board.workflow.new", "New workflow")}
aria-label={t("board.workflow.new", "New workflow")}
>
<Plus size={15} />
</button>
)}
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
</div>
) : null;
/*
FNXC:WorkflowControls 2026-06-20-00:00:
Board owns workflow selection state, so the existing selector/edit/create toolbar is portaled to Header only when the left sidebar is the active tablet/desktop navigation surface. If the Header slot is not mounted yet, render inline as the safe fallback so controls are never lost.
FNXC:WorkflowControls 2026-06-20-15:42:
Standalone workflow edit/create icon buttons were removed because those actions now live inside WorkflowSwitcher; keep this wrapper only when it contains the switcher to avoid empty toolbar shells.
*/
const relocatedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot && workflowToolbar
? createPortal(workflowToolbar, headerWorkflowSlot)

View File

@@ -44,10 +44,6 @@
min-width: 0;
}
.board-workflow-create-btn,
.board-workflow-edit-btn {
flex: 0 0 auto;
}
.board.board-workflow-columns {
flex: 1 1 auto;

View File

@@ -3,7 +3,7 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive, Plus } from "lucide-react";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
import { useColumnLabel } from "../i18n/labels";
@@ -242,6 +242,7 @@ interface ListViewProps {
lastFetchTimeMs?: number;
prAuthAvailable?: boolean;
autoMerge?: boolean;
onOpenWorkflowEditor?: (workflowId?: string) => void;
onCreateWorkflow?: () => void;
workflowColumnsEnabled?: boolean;
settingsLoaded?: boolean;
@@ -310,6 +311,7 @@ export function ListView({
lastFetchTimeMs,
prAuthAvailable,
autoMerge,
onOpenWorkflowEditor,
onCreateWorkflow,
workflowColumnsEnabled,
settingsLoaded,
@@ -1618,36 +1620,28 @@ export function ListView({
};
const renderWorkflowSelector = () => {
if (!workflowMode) return null;
const showSelect = workflowOptions.length > 1 && selectedWorkflow;
if (!showSelect && !onCreateWorkflow) return null;
if (!workflowMode || !selectedWorkflow) return null;
const shouldRenderWorkflowControls = workflowOptions.length > 1 || Boolean(onCreateWorkflow || onOpenWorkflowEditor);
if (!shouldRenderWorkflowControls || workflowOptions.length === 0) return null;
const workflowControl = (
<div className="list-workflow-control">
{showSelect && selectedWorkflow && (
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
label={t("listView.workflowLabel", "Workflow")}
/>
)}
{onCreateWorkflow && (
<button
type="button"
className="btn btn-icon btn-sm list-workflow-create-btn"
onClick={onCreateWorkflow}
title={t("workflows.newWorkflow", "New workflow")}
aria-label={t("workflows.newWorkflow", "New workflow")}
>
<Plus size={15} />
</button>
)}
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={workflowStatusCounts}
label={t("listView.workflowLabel", "Workflow")}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
);
/*
FNXC:WorkflowControls 2026-06-20-00:00:
ListView keeps its own workflow selection state and only portals its existing selector/create controls into Header when the sidebar header slot exists. It intentionally does not add the Board-only edit affordance.
ListView keeps its own workflow selection state and only portals its workflow controls into Header when the sidebar header slot exists.
FNXC:WorkflowControls 2026-06-20-15:43:
ListView now has edit parity through WorkflowSwitcher row actions and no longer renders a standalone create icon, preventing empty button shells across desktop and mobile header placements.
*/
return workflowControlsInHeader && headerWorkflowSlot
? createPortal(workflowControl, headerWorkflowSlot)

View File

@@ -131,12 +131,35 @@ The switcher's inline Todo, In Progress, and Done count badges intentionally mir
padding: var(--space-xs) 0;
}
.workflow-switcher-option-row {
display: flex;
align-items: stretch;
gap: var(--space-xs);
padding: 0 var(--space-xs);
background: transparent;
}
.workflow-switcher-option-row:hover,
.workflow-switcher-option-row--highlighted {
background: var(--card-hover);
}
.workflow-switcher-option-row--selected {
background: color-mix(in srgb, var(--todo) 15%, transparent);
}
.workflow-switcher-option-row--selected:hover,
.workflow-switcher-option-row--selected.workflow-switcher-option-row--highlighted {
background: color-mix(in srgb, var(--todo) 25%, transparent);
}
.workflow-switcher-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
width: 100%;
min-width: 0;
flex: 1 1 auto;
padding: var(--space-sm) var(--space-md);
border: 0;
background: transparent;
@@ -146,18 +169,21 @@ The switcher's inline Todo, In Progress, and Done count badges intentionally mir
cursor: pointer;
}
.workflow-switcher-option:hover,
.workflow-switcher-option--highlighted {
background: var(--card-hover);
.workflow-switcher-option:focus-visible,
.workflow-switcher-edit:focus-visible,
.workflow-switcher-create:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.workflow-switcher-option--selected {
background: color-mix(in srgb, var(--todo) 15%, transparent);
.workflow-switcher-edit {
flex: 0 0 auto;
align-self: center;
color: var(--text-muted);
}
.workflow-switcher-option--selected:hover,
.workflow-switcher-option--selected.workflow-switcher-option--highlighted {
background: color-mix(in srgb, var(--todo) 25%, transparent);
.workflow-switcher-edit:hover {
color: var(--text);
}
.workflow-switcher-option-name {
@@ -168,6 +194,22 @@ The switcher's inline Todo, In Progress, and Done count badges intentionally mir
margin-left: auto;
}
.workflow-switcher-footer {
flex: 0 0 auto;
padding: var(--space-xs);
border-top: 1px solid var(--border);
background: var(--surface);
}
.workflow-switcher-create {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
min-height: calc(var(--space-lg) + var(--space-sm));
}
@media (max-width: 768px) {
.workflow-switcher {
flex: 1 1 auto;

View File

@@ -1,6 +1,6 @@
import "./WorkflowSwitcher.css";
import { ChevronDown } from "lucide-react";
import { ChevronDown, Pencil, Plus } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -13,6 +13,8 @@ export interface WorkflowSwitcherProps {
onChange: (id: string) => void;
counts: Map<string, WorkflowStatusCounts>;
label?: string;
onEditWorkflow?: (workflowId: string) => void;
onCreateWorkflow?: () => void;
}
interface DropdownPosition {
@@ -36,13 +38,19 @@ function getCounts(counts: Map<string, WorkflowStatusCounts>, workflowId: string
* FNXC:WorkflowSwitcher 2026-06-20-00:31:
* Counts are contextual detail, so the collapsed trigger must stay visually and accessibly scoped to the active workflow name plus chevron.
* Render Todo, In Progress, and Done counts only while the dropdown is expanded; option rows keep their count text because the listbox is the comparison surface.
*
* FNXC:WorkflowSwitcher 2026-06-20-15:34:
* Workflow edit and creation affordances moved into the shared dropdown so Board and ListView cannot leave separate toolbar icon shells behind.
* Each option row owns a sibling edit button, and New workflow remains visible in a non-scrolling footer while long workflow lists scroll.
*/
export function WorkflowSwitcher({ workflows, value, onChange, counts, label: labelProp }: WorkflowSwitcherProps) {
export function WorkflowSwitcher({ workflows, value, onChange, counts, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) {
const { t } = useTranslation("app");
const label = labelProp ?? t("workflowSwitcher.label", "Workflow");
const todoLabel = t("workflowSwitcher.todo", "Todo");
const inProgressLabel = t("workflowSwitcher.inProgress", "In Progress");
const doneLabel = t("workflowSwitcher.done", "Done");
const editWorkflowLabel = t("workflowSwitcher.editWorkflow", "Edit workflow");
const newWorkflowLabel = t("workflowSwitcher.newWorkflow", "New workflow");
const listboxId = useId();
const [isOpen, setIsOpen] = useState(false);
@@ -139,6 +147,18 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
triggerRef.current?.focus();
}, [onChange]);
const handleEditWorkflow = useCallback((workflowId: string) => {
if (!onEditWorkflow) return;
setIsOpen(false);
onEditWorkflow(workflowId);
}, [onEditWorkflow]);
const handleCreateWorkflow = useCallback(() => {
if (!onCreateWorkflow) return;
setIsOpen(false);
onCreateWorkflow();
}, [onCreateWorkflow]);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
@@ -223,24 +243,56 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la
const isSelected = workflow.id === selectedWorkflow.id;
const isHighlighted = index === highlightedIndex;
return (
<button
<div
key={workflow.id}
type="button"
role="option"
aria-selected={isSelected}
data-index={index}
data-testid={`workflow-switcher-option-${workflow.id}`}
className={`workflow-switcher-option${isSelected ? " workflow-switcher-option--selected" : ""}${isHighlighted ? " workflow-switcher-option--highlighted" : ""}`}
className={`workflow-switcher-option-row${isSelected ? " workflow-switcher-option-row--selected" : ""}${isHighlighted ? " workflow-switcher-option-row--highlighted" : ""}`}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectWorkflow(workflow.id)}
>
<span className="workflow-switcher-option-name">{workflow.name}</span>
{renderCountBadges(workflowCounts, "option")}
{renderAccessibleCounts(workflowCounts)}
</button>
<button
type="button"
role="option"
aria-selected={isSelected}
data-index={index}
data-testid={`workflow-switcher-option-${workflow.id}`}
className="workflow-switcher-option"
onClick={() => selectWorkflow(workflow.id)}
>
<span className="workflow-switcher-option-name">{workflow.name}</span>
{renderCountBadges(workflowCounts, "option")}
{renderAccessibleCounts(workflowCounts)}
</button>
{onEditWorkflow ? (
<button
type="button"
className="btn btn-icon btn-sm workflow-switcher-edit"
data-testid={`workflow-switcher-edit-${workflow.id}`}
aria-label={editWorkflowLabel}
title={editWorkflowLabel}
onClick={(event) => {
event.stopPropagation();
handleEditWorkflow(workflow.id);
}}
>
<Pencil aria-hidden="true" />
</button>
) : null}
</div>
);
})}
</div>
{onCreateWorkflow ? (
<div className="workflow-switcher-footer">
<button
type="button"
className="btn workflow-switcher-create"
data-testid="workflow-switcher-create"
onClick={handleCreateWorkflow}
>
<Plus aria-hidden="true" />
<span>{newWorkflowLabel}</span>
</button>
</div>
) : null}
</div>,
portalRoot,
)

View File

@@ -940,13 +940,6 @@ describe("Board", () => {
});
}
function workflowToolbarActionNames() {
const toolbar = document.querySelector(".board-workflow-toolbar");
expect(toolbar).not.toBeNull();
return Array.from(toolbar?.querySelectorAll("button") ?? [])
.filter((button) => button.getAttribute("data-testid") !== "workflow-switcher")
.map((button) => button.getAttribute("aria-label"));
}
async function openWorkflowSwitcher() {
const trigger = await screen.findByTestId("workflow-switcher");
@@ -1041,11 +1034,14 @@ describe("Board", () => {
});
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(workflowToolbarActionNames()).toEqual(["Edit workflows", "New workflow"]);
const selector = screen.getByTestId("workflow-switcher");
expect(document.querySelector(".board-workflow-edit-btn")).toBeNull();
expect(document.querySelector(".board-workflow-create-btn")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-create"));
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-edit-builtin:coding"));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("builtin:coding");
@@ -1057,8 +1053,10 @@ describe("Board", () => {
const { unmount } = renderBoard({ onCreateWorkflow });
await waitFor(() => expect(document.querySelector(".board-workflow-toolbar")).not.toBeNull());
expect(workflowToolbarActionNames()).toEqual(["New workflow"]);
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
expect(document.querySelector(".board-workflow-create-btn")).toBeNull();
fireEvent.click(screen.getByTestId("workflow-switcher"));
fireEvent.click(screen.getByTestId("workflow-switcher-create"));
expect(screen.queryByTestId("workflow-switcher-edit-builtin:coding")).toBeNull();
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
unmount();
@@ -1067,8 +1065,10 @@ describe("Board", () => {
renderBoard({ onOpenWorkflowEditor });
await waitFor(() => expect(document.querySelector(".board-workflow-toolbar")).not.toBeNull());
expect(workflowToolbarActionNames()).toEqual(["Edit workflows"]);
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
expect(document.querySelector(".board-workflow-edit-btn")).toBeNull();
fireEvent.click(screen.getByTestId("workflow-switcher"));
expect(screen.queryByTestId("workflow-switcher-create")).toBeNull();
fireEvent.click(screen.getByTestId("workflow-switcher-edit-builtin:coding"));
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
});
@@ -1085,9 +1085,13 @@ describe("Board", () => {
onOpenWorkflowEditor,
});
expect(await screen.findByTestId("workflow-switcher")).toBeDefined();
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
const selector = await screen.findByTestId("workflow-switcher");
expect(document.querySelector(".board-workflow-edit-btn")).toBeNull();
expect(document.querySelector(".board-workflow-create-btn")).toBeNull();
fireEvent.click(selector);
expect(screen.getByTestId("workflow-switcher-create")).toBeDefined();
expect(screen.getByTestId("workflow-switcher-edit-builtin:coding")).toBeDefined();
expect(screen.getByTestId("workflow-switcher-edit-wf-custom")).toBeDefined();
const toolbar = document.querySelector(".board-workflow-toolbar");
expect(toolbar).not.toBeNull();
expect(toolbar?.hasAttribute("data-collapsed")).toBe(false);
@@ -1118,15 +1122,17 @@ describe("Board", () => {
const selector = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(headerSlot.querySelector(".board-workflow-toolbar")).not.toBeNull());
expect(headerSlot.contains(selector)).toBe(true);
expect(headerSlot.querySelector(".board-workflow-edit-btn")).not.toBeNull();
expect(headerSlot.querySelector(".board-workflow-create-btn")).not.toBeNull();
expect(headerSlot.querySelector(".board-workflow-edit-btn")).toBeNull();
expect(headerSlot.querySelector(".board-workflow-create-btn")).toBeNull();
expect(document.querySelector(".board-workflow-view > .board-workflow-toolbar")).toBeNull();
fireEvent.click(selector);
expect(screen.getByTestId("workflow-switcher-create")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-custom"));
await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined());
expect(screen.queryByTestId("column-todo")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-edit-wf-custom"));
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
} finally {
headerSlot.remove();
@@ -1196,9 +1202,12 @@ describe("Board", () => {
expect(selector).toHaveTextContent("1");
expect(screen.getByTestId("workflow-switcher-option-wf-custom")).toHaveTextContent("2");
fireEvent.keyDown(selector, { key: "Escape" });
expect(workflowToolbarActionNames()).toEqual(["Edit workflows", "New workflow"]);
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
expect(document.querySelector(".board-workflow-edit-btn")).toBeNull();
expect(document.querySelector(".board-workflow-create-btn")).toBeNull();
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-create"));
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-edit-builtin:coding"));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
@@ -1209,7 +1218,8 @@ describe("Board", () => {
expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort()).toEqual(["FN-2", "FN-3"]);
expect(screen.queryByTestId("column-todo")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
fireEvent.click(screen.getByTestId("workflow-switcher"));
fireEvent.click(screen.getByTestId("workflow-switcher-edit-wf-custom"));
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
});

View File

@@ -772,8 +772,9 @@ describe("ListView", () => {
mobileSpy.mockRestore();
});
it("shows a new-workflow action next to the workflow selector", async () => {
it("shows workflow edit and New actions inside the dropdown", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
@@ -801,17 +802,24 @@ describe("ListView", () => {
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Workflow task" })],
onCreateWorkflow,
onOpenWorkflowEditor,
});
await screen.findByTestId("workflow-switcher");
const createButtons = screen.getAllByRole("button", { name: "New workflow" });
expect(createButtons.length).toBeGreaterThan(0);
fireEvent.click(createButtons[0]);
const selector = await screen.findByTestId("workflow-switcher");
expect(document.querySelector(".list-workflow-create-btn")).toBeNull();
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-edit-wf-custom"));
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
expect(onCreateWorkflow).not.toHaveBeenCalled();
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-create"));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
});
it("relocates the list workflow selector and create action into the header slot without adding edit", async () => {
it("relocates the list workflow selector and dropdown actions into the header slot", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();
const headerSlot = document.createElement("div");
headerSlot.id = "header-workflow-slot";
headerSlot.className = "header-workflow-slot";
@@ -846,20 +854,25 @@ describe("ListView", () => {
createMockTask({ id: "FN-002", column: "backlog", title: "Custom task" }),
],
onCreateWorkflow,
onOpenWorkflowEditor,
workflowControlsInHeader: true,
});
const selector = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(headerSlot.querySelector(".list-workflow-control")).not.toBeNull());
expect(headerSlot.contains(selector)).toBe(true);
expect(headerSlot.querySelector(".list-workflow-create-btn")).not.toBeNull();
expect(headerSlot.querySelector(".list-workflow-create-btn")).toBeNull();
expect(headerSlot.querySelector(".board-workflow-edit-btn")).toBeNull();
expect(document.querySelector(".list-view > .list-workflow-control")).toBeNull();
fireEvent.click(selector);
expect(screen.getByTestId("workflow-switcher-create")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-custom"));
await waitFor(() => expect(screen.getByText("Custom task")).toBeInTheDocument());
expect(screen.queryByText("Coding task")).not.toBeInTheDocument();
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-edit-wf-custom"));
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
} finally {
headerSlot.remove();
}

View File

@@ -86,6 +86,90 @@ describe("WorkflowSwitcher", () => {
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
});
it("renders per-row edit buttons that close without selecting workflows", () => {
const onChange = vi.fn();
const onEditWorkflow = vi.fn();
render(
<WorkflowSwitcher
workflows={workflows}
value="coding"
onChange={onChange}
counts={countMap()}
onEditWorkflow={onEditWorkflow}
/>,
);
fireEvent.click(screen.getByTestId("workflow-switcher"));
fireEvent.click(screen.getByTestId("workflow-switcher-edit-design"));
expect(onEditWorkflow).toHaveBeenCalledWith("design");
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
});
it("renders a persistent New workflow footer for single and many workflow lists", () => {
const onCreateWorkflow = vi.fn();
const { rerender } = render(
<WorkflowSwitcher
workflows={[workflows[0]]}
value="coding"
onChange={vi.fn()}
counts={countMap()}
onCreateWorkflow={onCreateWorkflow}
/>,
);
fireEvent.click(screen.getByTestId("workflow-switcher"));
expect(screen.getByTestId("workflow-switcher-create")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("workflow-switcher-create"));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
const manyWorkflows = Array.from({ length: 8 }, (_, index) => ({
id: `workflow-${index}`,
name: `Workflow ${index}`,
columns: [],
}));
rerender(
<WorkflowSwitcher
workflows={manyWorkflows}
value="workflow-0"
onChange={vi.fn()}
counts={countMap()}
onCreateWorkflow={onCreateWorkflow}
/>,
);
fireEvent.click(screen.getByTestId("workflow-switcher"));
expect(screen.getByTestId("workflow-switcher-create")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(manyWorkflows.length);
});
it("keeps Enter selection scoped to highlighted workflow options when actions exist", () => {
const onChange = vi.fn();
const onEditWorkflow = vi.fn();
const onCreateWorkflow = vi.fn();
render(
<WorkflowSwitcher
workflows={workflows}
value="coding"
onChange={onChange}
counts={countMap()}
onEditWorkflow={onEditWorkflow}
onCreateWorkflow={onCreateWorkflow}
/>,
);
const trigger = screen.getByTestId("workflow-switcher");
fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(trigger, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("design");
expect(onEditWorkflow).not.toHaveBeenCalled();
expect(onCreateWorkflow).not.toHaveBeenCalled();
});
it("renders populated and zero counts only after the dropdown expands", () => {
render(
<WorkflowSwitcher

View File

@@ -8456,7 +8456,9 @@
"inProgress": "In Progress",
"label": "Workflow",
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}"
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
},
"workspace": {
"projectRoot": "Project Root",

View File

@@ -8456,7 +8456,9 @@
"inProgress": "In Progress",
"label": "Workflow",
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}"
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
},
"workspace": {
"projectRoot": "Raíz del proyecto",

View File

@@ -8456,7 +8456,9 @@
"inProgress": "In Progress",
"label": "Workflow",
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}"
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
},
"workspace": {
"projectRoot": "Racine du projet",

View File

@@ -8456,7 +8456,9 @@
"inProgress": "In Progress",
"label": "Workflow",
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}"
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
},
"workspace": {
"projectRoot": "프로젝트 루트",

View File

@@ -8456,7 +8456,9 @@
"inProgress": "In Progress",
"label": "Workflow",
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}"
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
},
"workspace": {
"projectRoot": "项目根目录",

View File

@@ -8456,7 +8456,9 @@
"inProgress": "In Progress",
"label": "Workflow",
"todo": "Todo",
"triggerAria": "Select workflow. Current workflow: {{name}}"
"triggerAria": "Select workflow. Current workflow: {{name}}",
"editWorkflow": "Edit workflow",
"newWorkflow": "New workflow"
},
"workspace": {
"projectRoot": "項目根目錄",