FN-6824: move workflow controls into header
Move workflow controls into the header when sidebar navigation is active. - Add a Header portal slot that replaces the hidden view toggle under left sidebar navigation. - Portal Board workflow selector, edit, and create controls into the Header slot with inline fallback. - Portal List workflow selector and create controls into the Header slot without adding edit controls. - Cover relocated and inline workflow control behavior for Board, Header, and List views. Files changed: docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 2 + packages/dashboard/app/components/Board.tsx | 99 ++++++++++++------- packages/dashboard/app/components/Header.css | 34 +++++++ packages/dashboard/app/components/Header.tsx | 11 +++ packages/dashboard/app/components/ListView.tsx | 25 ++++- .../app/components/__tests__/Board.test.tsx | 77 +++++++++++++++ .../app/components/__tests__/Header.test.tsx | 21 ++++ .../app/components/__tests__/ListView.test.tsx | 109 +++++++++++++++++++++ 9 files changed, 342 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-6824 Fusion-Task-Lineage: 01611a6c-2894-4f19-a5f8-262609528f49
This commit is contained in:
@@ -25,6 +25,8 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
On mobile viewports (`<=768px`), the sidebar is not rendered even when the experiment is enabled. The existing bottom `MobileNavBar` remains the navigation surface.
|
||||
|
||||
@@ -1852,6 +1852,7 @@ function AppInner() {
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||
settingsLoaded={settingsLoaded}
|
||||
workflowControlsInHeader={sidebarActive}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
@@ -1892,6 +1893,7 @@ function AppInner() {
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||
settingsLoaded={settingsLoaded}
|
||||
workflowControlsInHeader={sidebarActive}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import "./Lane.css";
|
||||
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";
|
||||
@@ -80,6 +81,8 @@ interface BoardProps {
|
||||
workflowColumnsEnabled?: boolean;
|
||||
/** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */
|
||||
settingsLoaded?: boolean;
|
||||
/** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */
|
||||
workflowControlsInHeader?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,12 +147,16 @@ 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 }: BoardProps) {
|
||||
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);
|
||||
const boardRef = useRef<HTMLElement | null>(null);
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.getElementById("header-workflow-slot");
|
||||
});
|
||||
const blockerFanoutMap = useBlockerFanout(tasks, {
|
||||
staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs,
|
||||
});
|
||||
@@ -164,6 +171,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
archived: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowControlsInHeader || typeof document === "undefined") {
|
||||
setHeaderWorkflowSlot(null);
|
||||
return;
|
||||
}
|
||||
setHeaderWorkflowSlot(document.getElementById("header-workflow-slot"));
|
||||
}, [workflowControlsInHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
recordResumeEvent({
|
||||
view: "Board",
|
||||
@@ -555,44 +570,54 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
}
|
||||
|
||||
if (workflowMode && selectedWorkflow) {
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
|
||||
<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>
|
||||
)}
|
||||
const shouldRenderWorkflowControls = workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor;
|
||||
const workflowToolbar = shouldRenderWorkflowControls ? (
|
||||
<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>
|
||||
) : 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.
|
||||
*/
|
||||
const relocatedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot && workflowToolbar
|
||||
? createPortal(workflowToolbar, headerWorkflowSlot)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar}
|
||||
<main
|
||||
className="board board-workflow-columns"
|
||||
id="board"
|
||||
|
||||
@@ -95,6 +95,40 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-workflow-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-workflow-slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-workflow-slot .board-workflow-toolbar,
|
||||
.header-workflow-slot .list-workflow-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-workflow-slot .board-workflow-selector {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-workflow-slot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -195,6 +195,9 @@ export function Header({
|
||||
/*
|
||||
FNXC:Navigation 2026-06-19-00:00:
|
||||
When experimental left sidebar navigation is active on tablet/desktop, Header must suppress its view-toggle and More-views trigger so there is one canonical non-mobile navigation surface and no orphaned chevron remains.
|
||||
|
||||
FNXC:WorkflowControls 2026-06-20-00:00:
|
||||
The hidden Header view-toggle location becomes the workflow-control portal slot only when left sidebar navigation is active on tablet/desktop. Mobile and flag-off paths keep workflow controls inline so the board/list chrome remains byte-identical.
|
||||
*/
|
||||
const hideHeaderViewNav = leftSidebarNavActive && !isMobile;
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
@@ -990,6 +993,14 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hideHeaderViewNav && (
|
||||
<div
|
||||
id="header-workflow-slot"
|
||||
className="header-workflow-slot"
|
||||
data-testid="header-workflow-slot"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Toggle - always inline, even on mobile */}
|
||||
{!hideFullNav && !hideHeaderViewNav && onChangeView && (
|
||||
<div className="view-toggle">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./ListView.css";
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
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";
|
||||
@@ -244,6 +245,8 @@ interface ListViewProps {
|
||||
onCreateWorkflow?: () => void;
|
||||
workflowColumnsEnabled?: boolean;
|
||||
settingsLoaded?: boolean;
|
||||
/** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */
|
||||
workflowControlsInHeader?: boolean;
|
||||
}
|
||||
|
||||
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
|
||||
@@ -310,6 +313,7 @@ export function ListView({
|
||||
onCreateWorkflow,
|
||||
workflowColumnsEnabled,
|
||||
settingsLoaded,
|
||||
workflowControlsInHeader = false,
|
||||
}: ListViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
@@ -329,10 +333,22 @@ export function ListView({
|
||||
});
|
||||
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.getElementById("header-workflow-slot");
|
||||
});
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const { confirm, confirmWithChoice } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowControlsInHeader || typeof document === "undefined") {
|
||||
setHeaderWorkflowSlot(null);
|
||||
return;
|
||||
}
|
||||
setHeaderWorkflowSlot(document.getElementById("header-workflow-slot"));
|
||||
}, [workflowControlsInHeader]);
|
||||
|
||||
// Column visibility state - initialize from localStorage or reduced default columns
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => readVisibleColumns(projectId));
|
||||
|
||||
@@ -1605,7 +1621,7 @@ export function ListView({
|
||||
if (!workflowMode) return null;
|
||||
const showSelect = workflowOptions.length > 1 && selectedWorkflow;
|
||||
if (!showSelect && !onCreateWorkflow) return null;
|
||||
return (
|
||||
const workflowControl = (
|
||||
<div className="list-workflow-control">
|
||||
{showSelect && selectedWorkflow && (
|
||||
<WorkflowSwitcher
|
||||
@@ -1629,6 +1645,13 @@ export function ListView({
|
||||
)}
|
||||
</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.
|
||||
*/
|
||||
return workflowControlsInHeader && headerWorkflowSlot
|
||||
? createPortal(workflowControl, headerWorkflowSlot)
|
||||
: workflowControl;
|
||||
};
|
||||
|
||||
const renderViewOptionsPanel = (panelId: string) => (
|
||||
|
||||
@@ -1096,6 +1096,83 @@ describe("Board", () => {
|
||||
expect(screen.queryByTestId("board-workflow-collapse-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("relocates workflow selector, edit, and create controls 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";
|
||||
document.body.appendChild(headerSlot);
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
try {
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })],
|
||||
onCreateWorkflow,
|
||||
onOpenWorkflowEditor,
|
||||
workflowControlsInHeader: true,
|
||||
});
|
||||
|
||||
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(document.querySelector(".board-workflow-view > .board-workflow-toolbar")).toBeNull();
|
||||
|
||||
fireEvent.click(selector);
|
||||
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" }));
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the board workflow toolbar inline when header relocation is inactive", async () => {
|
||||
const headerSlot = document.createElement("div");
|
||||
headerSlot.id = "header-workflow-slot";
|
||||
document.body.appendChild(headerSlot);
|
||||
enableFlag({ "FN-1": "builtin:coding", "FN-2": "wf-custom" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
try {
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })],
|
||||
onCreateWorkflow: vi.fn(),
|
||||
onOpenWorkflowEditor: vi.fn(),
|
||||
});
|
||||
|
||||
await screen.findByTestId("workflow-switcher");
|
||||
await waitFor(() => expect(document.querySelector(".board-workflow-view > .board-workflow-toolbar")).not.toBeNull());
|
||||
expect(headerSlot.querySelector(".board-workflow-toolbar")).toBeNull();
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leave a board workflow shell when header relocation has no controls", async () => {
|
||||
const headerSlot = document.createElement("div");
|
||||
headerSlot.id = "header-workflow-slot";
|
||||
document.body.appendChild(headerSlot);
|
||||
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]);
|
||||
try {
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-1" })],
|
||||
workflowControlsInHeader: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(document.querySelector(".board-workflow-toolbar")).toBeNull();
|
||||
expect(headerSlot.childElementCount).toBe(0);
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
|
||||
@@ -150,6 +150,27 @@ describe("Header", () => {
|
||||
expect(screen.getByTitle("List view")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the workflow portal slot instead of the view toggle on desktop sidebar nav", () => {
|
||||
renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "desktop");
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Board view")).toBeNull();
|
||||
expect(screen.queryByTitle("List view")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the workflow portal slot instead of the view toggle on tablet sidebar nav", () => {
|
||||
renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "tablet");
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Board view")).toBeNull();
|
||||
expect(screen.queryByTitle("List view")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the workflow portal slot on mobile sidebar nav", () => {
|
||||
renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "mobile");
|
||||
expect(screen.queryByTestId("header-workflow-slot")).toBeNull();
|
||||
expect(screen.queryByTitle("Board view")).not.toBeNull();
|
||||
expect(screen.queryByTitle("List view")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows board view as active by default", () => {
|
||||
renderHeader({ onChangeView: noop });
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
|
||||
@@ -810,6 +810,115 @@ describe("ListView", () => {
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("relocates the list workflow selector and create action into the header slot without adding edit", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const headerSlot = document.createElement("div");
|
||||
headerSlot.id = "header-workflow-slot";
|
||||
headerSlot.className = "header-workflow-slot";
|
||||
document.body.appendChild(headerSlot);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", flags: { intake: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", flags: { intake: true } },
|
||||
{ id: "complete", name: "Complete", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "builtin:coding", "FN-002": "wf-custom" },
|
||||
});
|
||||
try {
|
||||
renderListView({
|
||||
tasks: [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }),
|
||||
createMockTask({ id: "FN-002", column: "backlog", title: "Custom task" }),
|
||||
],
|
||||
onCreateWorkflow,
|
||||
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(".board-workflow-edit-btn")).toBeNull();
|
||||
expect(document.querySelector(".list-view > .list-workflow-control")).toBeNull();
|
||||
|
||||
fireEvent.click(selector);
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-custom"));
|
||||
await waitFor(() => expect(screen.getByText("Custom task")).toBeInTheDocument());
|
||||
expect(screen.queryByText("Coding task")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps list workflow controls inline when header relocation is inactive", async () => {
|
||||
const headerSlot = document.createElement("div");
|
||||
headerSlot.id = "header-workflow-slot";
|
||||
document.body.appendChild(headerSlot);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{ id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Triage", flags: { intake: true } }] },
|
||||
{ id: "wf-custom", name: "Custom", columns: [{ id: "backlog", name: "Backlog", flags: { intake: true } }] },
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "builtin:coding" },
|
||||
});
|
||||
try {
|
||||
renderListView({
|
||||
tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Coding task" })],
|
||||
onCreateWorkflow: vi.fn(),
|
||||
});
|
||||
|
||||
await screen.findByTestId("workflow-switcher");
|
||||
await waitFor(() => expect(document.querySelector(".list-view .list-workflow-control")).not.toBeNull());
|
||||
expect(headerSlot.querySelector(".list-workflow-control")).toBeNull();
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leave a list workflow shell when header relocation has no controls", async () => {
|
||||
const headerSlot = document.createElement("div");
|
||||
headerSlot.id = "header-workflow-slot";
|
||||
document.body.appendChild(headerSlot);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{ id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Triage", flags: { intake: true } }] },
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "builtin:coding" },
|
||||
});
|
||||
try {
|
||||
renderListView({
|
||||
tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Coding task" })],
|
||||
workflowControlsInHeader: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Coding task")).toBeInTheDocument());
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(document.querySelector(".list-workflow-control")).toBeNull();
|
||||
expect(headerSlot.childElementCount).toBe(0);
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps embedded selection visible when filters hide the selected row", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [
|
||||
|
||||
Reference in New Issue
Block a user