FN-7234: preserve board workflow selections
Persist workflow choices across dashboard board surfaces so operators return to their selected lane reliably. - Add project-scoped durable storage helpers for board workflow selection with stale-id validation and cleanup. - Share the workflow selection hook across Board, List, Header, and Graph surfaces while preserving cached payloads on transient refresh failures. - Update List quick-create/planning handoffs, cross-surface tests, and dashboard documentation for durable workflow selection behavior. - Add patch changesets for the published Fusion package. Files changed: .changeset/fn-7234-durable-board-workflow-selection.md | 7 ++ .changeset/workflow-selection-board-list.md | 7 ++ docs/dashboard-guide.md | 5 +- .../workflow-selection-cross-surface.test.tsx | 66 ++++++++-- packages/dashboard/app/components/ListView.tsx | 111 ++++------------- .../app/components/__tests__/Board.test.tsx | 45 +++++++ .../__tests__/GraphWorkflowSwitcherSlot.test.tsx | 57 +++++++++ .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx | 42 ++++++- .../app/components/__tests__/ListView.test.tsx | 122 +++++++++++++++++++ .../app/hooks/__tests__/useBoardWorkflows.test.ts | 29 +++++ packages/dashboard/app/hooks/useBoardWorkflows.ts | 64 ++++++++- .../utils/__tests__/boardWorkflowSelection.test.ts | 135 +++++++++++++++++++++ .../app/utils/__tests__/projectStorage.test.ts | 3 +- .../dashboard/app/utils/boardWorkflowSelection.ts | 49 ++++++++ packages/dashboard/app/utils/projectStorage.ts | 1 + 15 files changed, 633 insertions(+), 110 deletions(-) Fusion-Task-Id: FN-7234 Fusion-Task-Lineage: 6ebc000c-19c3-4a8e-9303-4e18a59902ed Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7234-durable-board-workflow-selection.md
Normal file
7
.changeset/fn-7234-durable-board-workflow-selection.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Preserve dashboard workflow selections per project across Board, List, Header, and Graph.
|
||||
category: fix
|
||||
dev: Board/List/Header/Graph workflow selection uses project-scoped localStorage and repairs stale ids.
|
||||
7
.changeset/workflow-selection-board-list.md
Normal file
7
.changeset/workflow-selection-board-list.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Board and List workflow choices selected across refreshes and route returns.
|
||||
category: fix
|
||||
dev: Uses project-scoped localStorage workflow-selection helpers shared with header and graph selectors.
|
||||
@@ -135,8 +135,9 @@ 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.
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
|
||||
- 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) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. 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.
|
||||
- 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. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, or respecification flows; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||
|
||||

|
||||
|
||||
@@ -190,7 +191,7 @@ Navigation:
|
||||
Behavior:
|
||||
- Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review`
|
||||
- Excludes `done` and `archived`
|
||||
- On desktop/tablet, the header workflow dropdown mirrors Board/List selection behavior and filters graph nodes to tasks assigned to the selected workflow; **All workflows** restores the full active-task graph.
|
||||
- On desktop/tablet, the header workflow dropdown mirrors Board/List selection behavior, restores the same per-project saved workflow when available, and filters graph nodes to tasks assigned to the selected workflow; **All workflows** restores the full active-task graph.
|
||||
- Uses Sugiyama-style layered auto-layout to place nodes by dependency depth
|
||||
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
|
||||
- Supports cursor-centered wheel zoom, pinch zoom, keyboard shortcuts (`Ctrl/Cmd+=`, `Ctrl/Cmd+-`, `Ctrl/Cmd+0`, `Ctrl/Cmd+Shift+F`, `Escape`), and fit/reset controls via the floating toolbar with live zoom percentage
|
||||
|
||||
@@ -81,6 +81,7 @@ function CrossSurfaceHarness({ projectId = "project-cross" }: { projectId?: stri
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
subscribeSseMock.mockClear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
|
||||
@@ -92,7 +93,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("workflow selection across dashboard surfaces", () => {
|
||||
it("hydrates remounted surfaces from the persisted board-workflows payload", async () => {
|
||||
/*
|
||||
FNXC:BoardWorkflowSelection 2026-06-29-13:30:
|
||||
Board workflow selectors keep independent mounted state for Header and Graph, but remounts intentionally hydrate from the same project-scoped durable workflow selection so fetch latency cannot bounce operators back to the default workflow.
|
||||
*/
|
||||
it("hydrates remounted Header and Graph surfaces from durable storage while fetch is pending", async () => {
|
||||
const { unmount } = render(<CrossSurfaceHarness />);
|
||||
|
||||
expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2);
|
||||
@@ -113,12 +118,12 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
|
||||
const remountedSwitchers = screen.getAllByTestId("workflow-switcher");
|
||||
expect(remountedSwitchers).toHaveLength(2);
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(GRAPH_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id);
|
||||
expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross");
|
||||
});
|
||||
|
||||
it("keeps Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => {
|
||||
it("keeps mounted Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => {
|
||||
render(<CrossSurfaceHarness />);
|
||||
|
||||
const switchers = await screen.findAllByTestId("workflow-switcher");
|
||||
@@ -154,31 +159,72 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));
|
||||
const { unmount } = render(<CrossSurfaceHarness />);
|
||||
it("rehydrates selection per project instead of carrying it across projects", async () => {
|
||||
const { rerender } = render(<CrossSurfaceHarness projectId="project-alpha" />);
|
||||
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
|
||||
const alphaSwitchers = await screen.findAllByTestId("workflow-switcher");
|
||||
fireEvent.click(alphaSwitchers[1]);
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`));
|
||||
await waitFor(() => expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id));
|
||||
|
||||
rerender(<CrossSurfaceHarness projectId="project-beta" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs stale stored workflow ids to the default workflow without hiding graph tasks", async () => {
|
||||
localStorage.setItem("kb:project-cross:kb-dashboard-board-workflow-selection", "wf-deleted");
|
||||
|
||||
render(<CrossSurfaceHarness />);
|
||||
|
||||
expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
expect(localStorage.getItem("kb:project-cross:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id);
|
||||
|
||||
const graphTasks = screen.getByTestId("graph-tasks");
|
||||
expect(within(graphTasks).getByTestId("graph-task-FN-default")).toBeInTheDocument();
|
||||
expect(within(graphTasks).getByTestId("graph-task-FN-unassigned")).toBeInTheDocument();
|
||||
expect(within(graphTasks).getByTestId("graph-task-FN-deleted")).toBeInTheDocument();
|
||||
expect(within(graphTasks).queryByTestId("graph-task-FN-graph")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => {
|
||||
localStorage.setItem("kb:project-disabled:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id);
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));
|
||||
const { unmount } = render(<CrossSurfaceHarness projectId="project-disabled" />);
|
||||
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-disabled"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
expect(localStorage.getItem("kb:project-disabled:kb-dashboard-board-workflow-selection")).toBeNull();
|
||||
for (const task of TASKS) {
|
||||
expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
unmount();
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("kb:project-empty:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id);
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] }));
|
||||
const empty = render(<CrossSurfaceHarness />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
|
||||
const empty = render(<CrossSurfaceHarness projectId="project-empty" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
expect(localStorage.getItem("kb:project-empty:kb-dashboard-board-workflow-selection")).toBeNull();
|
||||
empty.unmount();
|
||||
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("kb:project-single:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id);
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [DEFAULT_WORKFLOW] }));
|
||||
render(<CrossSurfaceHarness />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
|
||||
render(<CrossSurfaceHarness projectId="project-single" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-single"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
expect(localStorage.getItem("kb:project-single:kb-dashboard-board-workflow-selection")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,9 @@ import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult,
|
||||
import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||
import { batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { TaskDetailContent } from "./TaskDetailModal";
|
||||
import type { BoardWorkflowColumn, BoardWorkflowDefinition, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api";
|
||||
import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
@@ -21,10 +21,10 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -333,14 +333,21 @@ export function ListView({
|
||||
/*
|
||||
FNXC:BoardWorkflows 2026-06-20-09:07:
|
||||
ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown.
|
||||
|
||||
FNXC:BoardWorkflowSelection 2026-06-29-12:35:
|
||||
ListView must use the same project-scoped durable workflow selection invariant as Board/Header/Graph so task refreshes, respecification route returns, and remounts do not reset operators from a custom workflow back to the default workflow. Keep this separate from list task-selection storage keys.
|
||||
*/
|
||||
const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false;
|
||||
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
|
||||
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
|
||||
return cached ? { projectId, payload: cached } : null;
|
||||
});
|
||||
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const {
|
||||
boardWorkflows,
|
||||
workflowMode,
|
||||
workflowOptions,
|
||||
selectedWorkflow,
|
||||
selectedWorkflowId,
|
||||
setSelectedWorkflowId,
|
||||
refreshBoardWorkflows,
|
||||
setBoardWorkflowsState,
|
||||
} = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache });
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.getElementById("header-workflow-slot");
|
||||
@@ -414,7 +421,6 @@ export function ListView({
|
||||
// FNXC:ListView 2026-06-22-18:00: Holds the active pointer-drag teardown so move/up/cancel/unmount all detach the same listeners — prevents the "window mousemove with no cleanup" leak called out by the frontend-races review.
|
||||
const splitResizeTeardownRef = useRef<(() => void) | null>(null);
|
||||
const previousStorageProjectIdRef = useRef(projectId);
|
||||
const boardWorkflowsFetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousStorageProjectIdRef.current === projectId) return;
|
||||
@@ -433,55 +439,6 @@ export function ListView({
|
||||
setSidebarWidth(readSidebarWidth(projectId));
|
||||
}, [projectId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
|
||||
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||
}, [projectId, shouldHydrateBoardWorkflowsCache]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowControls 2026-06-21-00:00:
|
||||
Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events.
|
||||
Share this path with mount, visibility/focus, and workflow-definition SSE refetches so desktop sidebar and mobile toolbar counts cannot drift.
|
||||
*/
|
||||
const refreshBoardWorkflows = useCallback(() => {
|
||||
const seq = ++boardWorkflowsFetchSeqRef.current;
|
||||
fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||
setBoardWorkflowsState({ projectId, payload });
|
||||
writeBoardWorkflowsCache(projectId, payload);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
|
||||
}
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshBoardWorkflows();
|
||||
const onVisible = () => {
|
||||
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
|
||||
};
|
||||
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
||||
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const unsubscribe = subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"workflow:created": refreshBoardWorkflows,
|
||||
"workflow:updated": refreshBoardWorkflows,
|
||||
"workflow:deleted": refreshBoardWorkflows,
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
boardWorkflowsFetchSeqRef.current++;
|
||||
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
||||
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [projectId, refreshBoardWorkflows]);
|
||||
|
||||
// Persist selection to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -617,34 +574,6 @@ export function ListView({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const workflowMode = boardWorkflows?.flagEnabled === true && boardWorkflows.workflows.length > 0;
|
||||
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
|
||||
if (!workflowMode || !boardWorkflows) return [];
|
||||
return [...boardWorkflows.workflows].sort((a, b) => {
|
||||
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
|
||||
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [boardWorkflows, workflowMode]);
|
||||
|
||||
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
|
||||
if (!workflowMode) return null;
|
||||
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
|
||||
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
|
||||
?? workflowOptions[0]
|
||||
?? null;
|
||||
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowMode) {
|
||||
setSelectedWorkflowId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
|
||||
setSelectedWorkflowId(selectedWorkflow.id);
|
||||
}
|
||||
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColumn(null);
|
||||
}, [selectedWorkflowId]);
|
||||
@@ -743,6 +672,11 @@ export function ListView({
|
||||
return create(input);
|
||||
}, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, selectedWorkflow, t, workflowMode]);
|
||||
|
||||
/*
|
||||
FNXC:ListWorkflowSelection 2026-06-29-00:00:
|
||||
List quick-add Plan/Subtask handoffs must inherit the same active workflow as direct quick-create. Passing null only while workflow mode has no selected workflow preserves stale-id fallback behavior without reverting to the project default lane.
|
||||
*/
|
||||
const listQuickEntryWorkflowId = workflowMode ? selectedWorkflow?.id ?? null : undefined;
|
||||
|
||||
// Column display labels
|
||||
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
|
||||
@@ -1997,6 +1931,7 @@ export function ListView({
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
workflowId={listQuickEntryWorkflowId}
|
||||
projectId={projectId}
|
||||
autoExpand={false}
|
||||
defaultExpanded={false}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { Board } from "../Board";
|
||||
import { COLUMNS } from "@fusion/core";
|
||||
import { BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
@@ -962,6 +964,49 @@ describe("Board", () => {
|
||||
expect(screen.queryByTestId(/^lane-/)).toBeNull();
|
||||
});
|
||||
|
||||
it("hydrates remounted board workflow selection from durable project storage", async () => {
|
||||
const projectId = "project-board-persist";
|
||||
enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
const { unmount } = renderBoard({ projectId });
|
||||
|
||||
await selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name));
|
||||
expect(window.localStorage.getItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId))).toBe(CUSTOM_WORKFLOW.id);
|
||||
|
||||
unmount();
|
||||
enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
renderBoard({ projectId });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name));
|
||||
});
|
||||
|
||||
it("keeps a custom board workflow selected after task refresh and workflow payload revalidation", async () => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
taskWorkflowIds: { "FN-1": "wf-custom" },
|
||||
});
|
||||
const { rerender } = renderBoard({
|
||||
projectId: "project-board-refresh",
|
||||
tasks: [mkTask({ id: "FN-1", column: "intake", title: "Custom task" })],
|
||||
});
|
||||
|
||||
await selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name));
|
||||
|
||||
rerender(<Board {...createBoardProps({
|
||||
projectId: "project-board-refresh",
|
||||
tasks: [mkTask({ id: "FN-1", column: "done", title: "Custom task after respec" })],
|
||||
})} />);
|
||||
await act(async () => {
|
||||
sseHandlers["workflow:updated"]?.();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name));
|
||||
expect(screen.getByTestId("column-done")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1"));
|
||||
});
|
||||
|
||||
it("tasks with no selection render in the default selected workflow", async () => {
|
||||
enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" });
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] });
|
||||
|
||||
@@ -46,6 +46,7 @@ function appendHeaderWorkflowSlot() {
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
subscribeSseMock.mockClear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
|
||||
@@ -141,6 +142,62 @@ describe("GraphWorkflowSwitcherSlot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates a remounted graph selector from durable project storage while fetch is pending", async () => {
|
||||
appendHeaderWorkflowSlot();
|
||||
const { unmount } = render(<GraphWorkflowSwitcherSlot projectId="project-graph-persist" />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workflow-switcher"));
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review"));
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name));
|
||||
|
||||
unmount();
|
||||
fetchBoardWorkflowsMock.mockImplementation(() => new Promise<BoardWorkflowsPayload>(() => {}));
|
||||
render(<GraphWorkflowSwitcherSlot projectId="project-graph-persist" />);
|
||||
|
||||
expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(CUSTOM_WORKFLOW.name);
|
||||
});
|
||||
|
||||
it("repairs a stale stored graph workflow id to the default workflow", async () => {
|
||||
appendHeaderWorkflowSlot();
|
||||
localStorage.setItem("kb:project-graph-stale:kb-dashboard-board-workflow-selection", "wf-deleted");
|
||||
const onWorkflowSelectionChange = vi.fn();
|
||||
|
||||
render(<GraphWorkflowSwitcherSlot projectId="project-graph-stale" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
|
||||
|
||||
expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(DEFAULT_WORKFLOW.name);
|
||||
await waitFor(() => {
|
||||
const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null;
|
||||
expect(lastSelection?.selectedWorkflow.id).toBe(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
expect(localStorage.getItem("kb:project-graph-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
|
||||
it("keeps a valid graph workflow selection through focus refreshes", async () => {
|
||||
appendHeaderWorkflowSlot();
|
||||
const onWorkflowSelectionChange = vi.fn();
|
||||
render(<GraphWorkflowSwitcherSlot projectId="project-focus" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workflow-switcher"));
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review"));
|
||||
await waitFor(() => {
|
||||
const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null;
|
||||
expect(lastSelection?.selectedWorkflow.id).toBe(CUSTOM_WORKFLOW.id);
|
||||
});
|
||||
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({
|
||||
defaultWorkflowId: DEFAULT_WORKFLOW.id,
|
||||
workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
}));
|
||||
const callsBeforeFocus = fetchBoardWorkflowsMock.mock.calls.length;
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(callsBeforeFocus + 1));
|
||||
await waitFor(() => {
|
||||
const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null;
|
||||
expect(lastSelection?.selectedWorkflow.id).toBe(CUSTOM_WORKFLOW.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards dropdown edit workflow ids to the graph editor launcher", async () => {
|
||||
appendHeaderWorkflowSlot();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
|
||||
@@ -54,6 +54,7 @@ function renderWithHeader(children: ReactNode) {
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
subscribeSseMock.mockClear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
|
||||
@@ -99,6 +100,37 @@ describe("HeaderWorkflowSwitcherSlot", () => {
|
||||
expect(headerSlot.contains(selector)).toBe(true);
|
||||
});
|
||||
|
||||
it("hydrates a remounted desktop header selector from durable project storage", async () => {
|
||||
const { unmount } = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-persist" />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workflow-switcher"));
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-missions"));
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(MISSION_WORKFLOW.name));
|
||||
|
||||
unmount();
|
||||
fetchBoardWorkflowsMock.mockImplementation(() => new Promise<BoardWorkflowsPayload>(() => {}));
|
||||
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-persist" />);
|
||||
|
||||
expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(MISSION_WORKFLOW.name);
|
||||
});
|
||||
|
||||
it("repairs a stale stored header workflow id to the default workflow", async () => {
|
||||
localStorage.setItem("kb:project-header-stale:kb-dashboard-board-workflow-selection", "wf-deleted");
|
||||
const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
|
||||
|
||||
renderWithHeader(
|
||||
<HeaderWorkflowSwitcherSlot projectId="project-header-stale" onWorkflowSelectionChange={onWorkflowSelectionChange} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(DEFAULT_WORKFLOW.name);
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
|
||||
}));
|
||||
});
|
||||
expect(localStorage.getItem("kb:project-header-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
|
||||
it("forwards dropdown edit workflow ids from the shared header slot", async () => {
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-edit" onOpenWorkflowEditor={onOpenWorkflowEditor} />);
|
||||
@@ -109,7 +141,7 @@ describe("HeaderWorkflowSwitcherSlot", () => {
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-missions");
|
||||
});
|
||||
|
||||
it("renders no toolbar shell when workflow mode is off or only one workflow exists", async () => {
|
||||
it("renders no toolbar shell when workflow mode is off, empty, or only one workflow exists", async () => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ flagEnabled: false, workflows: [] }));
|
||||
const { unmount } = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-off" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-off"));
|
||||
@@ -118,6 +150,14 @@ describe("HeaderWorkflowSwitcherSlot", () => {
|
||||
|
||||
unmount();
|
||||
sessionStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [] }));
|
||||
const empty = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-empty" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
|
||||
empty.unmount();
|
||||
sessionStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [DEFAULT_WORKFLOW] }));
|
||||
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-one" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-one"));
|
||||
|
||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { ListView } from "../ListView";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
import { BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
// Mock the API
|
||||
@@ -46,9 +47,15 @@ vi.mock("../QuickEntryBox", () => ({
|
||||
QuickEntryBox: ({
|
||||
onCreate,
|
||||
addToast,
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
workflowId,
|
||||
}: {
|
||||
onCreate?: (input: { description: string }) => Promise<unknown>;
|
||||
addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
workflowId?: string | null;
|
||||
}) => {
|
||||
const [value, setValue] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -65,6 +72,16 @@ vi.mock("../QuickEntryBox", () => ({
|
||||
}
|
||||
};
|
||||
|
||||
const handoff = (callback?: (description: string, workflowId?: string | null) => void) => {
|
||||
const description = value.trim();
|
||||
if (!description || !callback) return;
|
||||
if (workflowId !== undefined) {
|
||||
callback(description, workflowId);
|
||||
return;
|
||||
}
|
||||
callback(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quick-entry-box" data-testid="quick-entry-box">
|
||||
<textarea
|
||||
@@ -96,6 +113,12 @@ vi.mock("../QuickEntryBox", () => ({
|
||||
Models
|
||||
</button>
|
||||
<button type="button" data-testid="quick-entry-deps">Deps</button>
|
||||
<button type="button" data-testid="quick-entry-plan" onClick={() => handoff(onPlanningMode)}>
|
||||
Plan
|
||||
</button>
|
||||
<button type="button" data-testid="quick-entry-subtask" onClick={() => handoff(onSubtaskBreakdown)}>
|
||||
Subtask
|
||||
</button>
|
||||
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
|
||||
Save
|
||||
</button>
|
||||
@@ -697,6 +720,72 @@ describe("ListView", () => {
|
||||
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a custom list workflow selected after task refresh and workflow payload revalidation", async () => {
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [
|
||||
{ id: "todo", name: "Todo", flags: { intake: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom Flow",
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", flags: { intake: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-list-columns"), JSON.stringify(["title", "status"]));
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-002"]));
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-list-collapsed"), JSON.stringify(["archived"]));
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-list-sidebar-width"), "420");
|
||||
|
||||
const listProps: React.ComponentProps<typeof ListView> = {
|
||||
tasks: [createMockTask({ id: "FN-001", column: "backlog", title: "Custom workflow task" })],
|
||||
onMoveTask: vi.fn(async () => createMockTask()),
|
||||
onRetryTask: vi.fn(async () => createMockTask()),
|
||||
onDeleteTask: vi.fn(async () => createMockTask()),
|
||||
onMergeTask: vi.fn(async () => ({ merged: false })),
|
||||
onResetTask: vi.fn(async () => createMockTask()),
|
||||
onDuplicateTask: vi.fn(async () => createMockTask()),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: mockAddToast,
|
||||
globalPaused: false,
|
||||
onNewTask: vi.fn(),
|
||||
projectId: TEST_PROJECT_ID,
|
||||
};
|
||||
const rendered = render(<ListView {...listProps} />);
|
||||
|
||||
await selectWorkflow("wf-custom");
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Custom Flow"));
|
||||
expect(window.localStorage.getItem(scopedStorageKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY))).toBe("wf-custom");
|
||||
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-list-columns"))).toBe(JSON.stringify(["title", "status"]));
|
||||
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toBe(JSON.stringify(["FN-002"]));
|
||||
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-list-collapsed"))).toBe(JSON.stringify(["archived"]));
|
||||
expect(window.localStorage.getItem(scopedStorageKey("kb-dashboard-list-sidebar-width"))).toBe("420");
|
||||
expect(screen.getByText("Custom workflow task")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rendered.rerender(<ListView {...listProps} tasks={[createMockTask({ id: "FN-001", column: "done", title: "Custom workflow task after respec" })]} />);
|
||||
});
|
||||
await act(async () => {
|
||||
listViewSseHandlers["workflow:updated"]?.();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Custom Flow"));
|
||||
expect(screen.getByText("Custom workflow task after respec")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-homes a preserved-column task to the new workflow after workflow invalidation", async () => {
|
||||
const preservedWorkflow = {
|
||||
id: "wf-preserved",
|
||||
@@ -2824,6 +2913,39 @@ describe("ListView Quick Entry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the selected workflow id to list quick-entry Plan and Subtask handoffs", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:default",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:default",
|
||||
name: "Default",
|
||||
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
|
||||
},
|
||||
{
|
||||
id: "wf-list-active",
|
||||
name: "List Active",
|
||||
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderListView({ onPlanningMode, onSubtaskBreakdown });
|
||||
|
||||
await selectWorkflow("wf-list-active");
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "Plan on selected list workflow" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-toggle"));
|
||||
fireEvent.click(screen.getByTestId("quick-entry-plan"));
|
||||
fireEvent.click(screen.getByTestId("quick-entry-subtask"));
|
||||
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Plan on selected list workflow", "wf-list-active");
|
||||
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Plan on selected list workflow", "wf-list-active");
|
||||
});
|
||||
|
||||
it("shows error toast when onQuickCreate fails and keeps input content", async () => {
|
||||
const mockOnQuickCreate = vi.fn().mockRejectedValue(new Error("Create failed"));
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
@@ -23,6 +23,8 @@ describe("useBoardWorkflows", () => {
|
||||
beforeEach(() => {
|
||||
subscribeHandlers = {};
|
||||
unsubscribe = vi.fn();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
function makeDeps(fetchImpl: () => Promise<BoardWorkflowsPayload>) {
|
||||
@@ -197,6 +199,33 @@ describe("useBoardWorkflows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the current payload and durable selection when a refresh fetch fails", async () => {
|
||||
let shouldReject = false;
|
||||
const deps = makeDeps(() => {
|
||||
if (shouldReject) return Promise.reject(new Error("temporary workflow API failure"));
|
||||
return Promise.resolve(makePayload());
|
||||
});
|
||||
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
|
||||
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-a"));
|
||||
act(() => { result.current.setSelectedWorkflowId("wf-b"); });
|
||||
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-b"));
|
||||
expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBe("wf-b");
|
||||
|
||||
shouldReject = true;
|
||||
await act(async () => {
|
||||
result.current.refreshBoardWorkflows();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.workflowMode).toBe(true);
|
||||
expect(result.current.boardWorkflows).toEqual(makePayload());
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-b");
|
||||
expect(result.current.selectedWorkflowId).toBe("wf-b");
|
||||
expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBe("wf-b");
|
||||
});
|
||||
|
||||
it("keeps selected workflow state isolated per hook consumer", async () => {
|
||||
const depsOne = makeDeps(() => Promise.resolve(makePayload()));
|
||||
const depsTwo = makeDeps(() => Promise.resolve(makePayload()));
|
||||
|
||||
@@ -9,10 +9,18 @@ import {
|
||||
readBoardWorkflowsCache as defaultReadBoardWorkflowsCache,
|
||||
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
|
||||
} from "../utils/boardWorkflowsCache";
|
||||
import {
|
||||
readBoardWorkflowSelection,
|
||||
removeBoardWorkflowSelection,
|
||||
writeBoardWorkflowSelection,
|
||||
} from "../utils/boardWorkflowSelection";
|
||||
|
||||
/*
|
||||
FNXC:Workflows 2026-06-22-17:00:
|
||||
Single source of truth for board-workflow fetch/cache/SSE/selection, shared verbatim by Board.tsx and the Planning header slot (PlanningWorkflowSwitcherSlot.tsx). Both surfaces must show the SAME workflow dropdown driven by the SAME data path: refetch on mount, on tab visibility/focus, and on `workflow:created|updated|deleted` SSE; every fetch is guarded by a monotonic sequence ref that drops out-of-order responses; successful payloads persist to the per-project session cache; failures collapse to a flag-off payload. Selection (`selectedWorkflowId`) is local per-consumer and auto-syncs to the resolved default/first workflow.
|
||||
Single source of truth for board-workflow fetch/cache/SSE/selection, shared verbatim by Board.tsx and the Planning header slot (PlanningWorkflowSwitcherSlot.tsx). Both surfaces must show the SAME workflow dropdown driven by the SAME data path: refetch on mount, on tab visibility/focus, and on `workflow:created|updated|deleted` SSE; every fetch is guarded by a monotonic sequence ref that drops out-of-order responses; successful payloads persist to the per-project session cache. Selection (`selectedWorkflowId`) hydrates from project-scoped durable storage, user changes write immediately, and stale stored ids are repaired only after the current payload proves the workflow no longer exists.
|
||||
|
||||
FNXC:Workflows 2026-06-29-14:45:
|
||||
Transient board-workflows fetch failures are not authoritative workflow-mode disable signals. Preserve the last payload and durable workflow selection on API/focus/refresh blips so operators return to their selected lane unless the server explicitly returns workflow mode off, an empty list, or a single unswitchable workflow.
|
||||
|
||||
Per-consumer subscription semantics are preserved: each call to this hook installs its OWN visibilitychange/focus listeners and its OWN SSE subscription, so two consumers (Board + Planning slot) each subscribe and unsubscribe independently — the hook does not dedupe across consumers. Dependencies (fetch, subscribeSse, cache helpers) are injectable to keep the hook DI-friendly and free of App-level singletons.
|
||||
*/
|
||||
@@ -65,7 +73,23 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
return cached ? { projectId, payload: cached } : null;
|
||||
});
|
||||
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowSelection(projectId));
|
||||
const storedSelectionRef = useRef<string | null>(selectedWorkflowId);
|
||||
|
||||
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((nextSelection) => {
|
||||
setSelectedWorkflowIdState((previousSelection) => {
|
||||
const resolvedSelection = typeof nextSelection === "function"
|
||||
? nextSelection(previousSelection)
|
||||
: nextSelection;
|
||||
storedSelectionRef.current = resolvedSelection;
|
||||
if (resolvedSelection) {
|
||||
writeBoardWorkflowSelection(projectId, resolvedSelection);
|
||||
} else {
|
||||
removeBoardWorkflowSelection(projectId);
|
||||
}
|
||||
return resolvedSelection;
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
// Stale-response guard: a monotonic sequence ref drops out-of-order responses.
|
||||
const boardWorkflowsFetchSeqRef = useRef(0);
|
||||
@@ -73,6 +97,9 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
// Re-hydrate from the per-project cache on project change (and gate change).
|
||||
useEffect(() => {
|
||||
const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null;
|
||||
const storedSelection = readBoardWorkflowSelection(projectId);
|
||||
storedSelectionRef.current = storedSelection;
|
||||
setSelectedWorkflowIdState(storedSelection);
|
||||
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||
}, [projectId, shouldHydrateCache, readBoardWorkflowsCache]);
|
||||
|
||||
@@ -86,9 +113,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
|
||||
}
|
||||
// Fetch failures are non-authoritative: keep the current/cache-hydrated payload so the cleanup effect does not erase durable selection.
|
||||
});
|
||||
}, [projectId, fetchBoardWorkflows, writeBoardWorkflowsCache]);
|
||||
|
||||
@@ -137,14 +162,37 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowMode) {
|
||||
setSelectedWorkflowId(null);
|
||||
if (!boardWorkflows) {
|
||||
return;
|
||||
}
|
||||
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
|
||||
setSelectedWorkflowId(selectedWorkflow.id);
|
||||
|
||||
if (!workflowMode) {
|
||||
if (storedSelectionRef.current !== null) {
|
||||
removeBoardWorkflowSelection(projectId);
|
||||
storedSelectionRef.current = null;
|
||||
}
|
||||
setSelectedWorkflowIdState(null);
|
||||
return;
|
||||
}
|
||||
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
|
||||
|
||||
if (workflowOptions.length < 2) {
|
||||
if (storedSelectionRef.current !== null) {
|
||||
removeBoardWorkflowSelection(projectId);
|
||||
storedSelectionRef.current = null;
|
||||
}
|
||||
setSelectedWorkflowIdState(selectedWorkflow?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
|
||||
const shouldRepairStoredSelection = storedSelectionRef.current !== null;
|
||||
setSelectedWorkflowIdState(selectedWorkflow.id);
|
||||
if (shouldRepairStoredSelection) {
|
||||
writeBoardWorkflowSelection(projectId, selectedWorkflow.id);
|
||||
storedSelectionRef.current = selectedWorkflow.id;
|
||||
}
|
||||
}
|
||||
}, [boardWorkflows, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
|
||||
|
||||
return {
|
||||
boardWorkflows,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
BOARD_WORKFLOW_SELECTION_STORAGE_KEY,
|
||||
readBoardWorkflowSelection,
|
||||
removeBoardWorkflowSelection,
|
||||
writeBoardWorkflowSelection,
|
||||
} from "../boardWorkflowSelection";
|
||||
import { scopedKey } from "../projectStorage";
|
||||
|
||||
const projectKey = (projectId: string) => scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
|
||||
|
||||
describe("boardWorkflowSelection", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("persists selected workflow ids per project", () => {
|
||||
writeBoardWorkflowSelection("project-a", "builtin:coding");
|
||||
writeBoardWorkflowSelection("project-b", "WF-123");
|
||||
|
||||
expect(window.localStorage.getItem(projectKey("project-a"))).toBe("builtin:coding");
|
||||
expect(window.localStorage.getItem(projectKey("project-b"))).toBe("WF-123");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBe("builtin:coding");
|
||||
expect(readBoardWorkflowSelection("project-b")).toBe("WF-123");
|
||||
});
|
||||
|
||||
it("uses the unscoped default when no project is selected", () => {
|
||||
writeBoardWorkflowSelection(undefined, "default-workflow");
|
||||
writeBoardWorkflowSelection("project-a", "project-workflow");
|
||||
|
||||
expect(window.localStorage.getItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY)).toBe("default-workflow");
|
||||
expect(readBoardWorkflowSelection()).toBe("default-workflow");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBe("project-workflow");
|
||||
});
|
||||
|
||||
it("ignores malformed or empty stored values", () => {
|
||||
window.localStorage.setItem(projectKey("empty"), " ");
|
||||
window.localStorage.setItem(projectKey("object"), JSON.stringify({ id: "builtin:coding" }));
|
||||
window.localStorage.setItem(projectKey("array"), JSON.stringify(["builtin:coding"]));
|
||||
window.localStorage.setItem(projectKey("control"), "workflow\u0000id");
|
||||
|
||||
expect(readBoardWorkflowSelection("empty")).toBeNull();
|
||||
expect(readBoardWorkflowSelection("object")).toBeNull();
|
||||
expect(readBoardWorkflowSelection("array")).toBeNull();
|
||||
expect(readBoardWorkflowSelection("control")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims valid workflow ids before storing and reading", () => {
|
||||
writeBoardWorkflowSelection("project-a", " builtin:coding ");
|
||||
|
||||
expect(window.localStorage.getItem(projectKey("project-a"))).toBe("builtin:coding");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBe("builtin:coding");
|
||||
});
|
||||
|
||||
it("removes the stored selection when writing an empty or malformed value", () => {
|
||||
writeBoardWorkflowSelection("project-a", "builtin:coding");
|
||||
writeBoardWorkflowSelection("project-a", " ");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
|
||||
writeBoardWorkflowSelection("project-a", "builtin:coding");
|
||||
writeBoardWorkflowSelection("project-a", "{\"id\":\"builtin:coding\"}");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
|
||||
writeBoardWorkflowSelection("project-a", "builtin:coding");
|
||||
writeBoardWorkflowSelection("project-a", "workflow\u0000id");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes stored selections for the requested project only", () => {
|
||||
writeBoardWorkflowSelection("project-a", "builtin:coding");
|
||||
writeBoardWorkflowSelection("project-b", "WF-123");
|
||||
|
||||
removeBoardWorkflowSelection("project-a");
|
||||
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
expect(readBoardWorkflowSelection("project-b")).toBe("WF-123");
|
||||
});
|
||||
|
||||
it("returns null and no-ops when storage APIs are unavailable", () => {
|
||||
vi.stubGlobal("window", { localStorage: {} });
|
||||
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
expect(() => writeBoardWorkflowSelection("project-a", "builtin:coding")).not.toThrow();
|
||||
expect(() => removeBoardWorkflowSelection("project-a")).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns null and no-ops without a browser window", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
expect(() => writeBoardWorkflowSelection("project-a", "builtin:coding")).not.toThrow();
|
||||
expect(() => removeBoardWorkflowSelection("project-a")).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows localStorage read, write, and remove failures", () => {
|
||||
vi.stubGlobal("window", {
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
throw new Error("private mode read");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
localStorage: {
|
||||
setItem: () => {
|
||||
throw new Error("quota");
|
||||
},
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(() => writeBoardWorkflowSelection("project-a", "builtin:coding")).not.toThrow();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
localStorage: {
|
||||
removeItem: () => {
|
||||
throw new Error("private mode remove");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => removeBoardWorkflowSelection("project-a")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -103,10 +103,11 @@ describe("projectStorage", () => {
|
||||
"kb-capacity-risk-banner-dismissed",
|
||||
"kb-files-line-numbers",
|
||||
"kb-dashboard-dock-files-current",
|
||||
"kb-dashboard-board-workflow-selection",
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(28);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(29);
|
||||
});
|
||||
|
||||
it("stores branch filter values as scoped strings per project", () => {
|
||||
|
||||
49
packages/dashboard/app/utils/boardWorkflowSelection.ts
Normal file
49
packages/dashboard/app/utils/boardWorkflowSelection.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "./projectStorage";
|
||||
|
||||
export const BOARD_WORKFLOW_SELECTION_STORAGE_KEY = "kb-dashboard-board-workflow-selection";
|
||||
|
||||
function isValidWorkflowSelection(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return false;
|
||||
if (/\p{C}/u.test(trimmed)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:BoardWorkflowSelection 2026-06-29-12:00:
|
||||
* Persist only the last selected workflow id in project-scoped localStorage so Board, Header, Graph, and List selectors can restore the operator's workflow after board remounts, task state changes, respecification returns, and browser/server restarts. Storage is best-effort because private-mode, SSR, missing APIs, and quota failures must never block board rendering.
|
||||
*/
|
||||
export function readBoardWorkflowSelection(projectId?: string): string | null {
|
||||
try {
|
||||
const stored = getScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
|
||||
return isValidWorkflowSelection(stored) ? stored.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBoardWorkflowSelection(projectId: string | undefined, workflowId: string): void {
|
||||
try {
|
||||
const trimmed = workflowId.trim();
|
||||
if (!isValidWorkflowSelection(trimmed)) {
|
||||
removeScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
setScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, trimmed, projectId);
|
||||
} catch {
|
||||
// Best-effort preference persistence; board rendering must continue on storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBoardWorkflowSelection(projectId?: string): void {
|
||||
try {
|
||||
removeScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
|
||||
} catch {
|
||||
// Best-effort preference cleanup; storage failures are non-fatal.
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-capacity-risk-banner-dismissed",
|
||||
"kb-files-line-numbers",
|
||||
"kb-dashboard-dock-files-current",
|
||||
"kb-dashboard-board-workflow-selection",
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user