FN-7280: persist all-workflows board view
Persist the Board aggregate workflow view so refreshes restore the operator's last board selection. - Store the All workflows sentinel in project-scoped board view preferences while filtering it out for real workflow consumers. - Restore all-workflows selection on remount/project changes and clear it when workflow mode is unavailable. - Cover refresh persistence, project scoping, hooks, utilities, and cross-surface behavior with tests. - Document the refresh-persistent All workflows behavior and add a patch changeset. Files changed: .changeset/fn-7280-all-workflows-board-view.md | 7 +++ docs/dashboard-guide.md | 7 +-- .../workflow-selection-cross-surface.test.tsx | 15 ++++++ packages/dashboard/app/components/Board.tsx | 31 +++++++++--- .../app/components/__tests__/Board.test.tsx | 58 ++++++++++++++++++++-- .../app/hooks/__tests__/useBoardWorkflows.test.ts | 11 ++++ .../utils/__tests__/boardWorkflowSelection.test.ts | 13 +++++ .../dashboard/app/utils/boardWorkflowSelection.ts | 13 ++++- 8 files changed, 137 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7280 Fusion-Task-Lineage: 12d83b05-5b2b-4435-9383-30b35c07c087 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7280-all-workflows-board-view.md
Normal file
7
.changeset/fn-7280-all-workflows-board-view.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore the Board All workflows view after refresh.
|
||||
category: fix
|
||||
dev: Persists the Board-only aggregate workflow sentinel while filtering it out of real-workflow selectors.
|
||||
@@ -150,12 +150,13 @@ FNXC:BoardCardActions 2026-06-30-13:12: Card context menus must not label an act
|
||||
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The sort actions are only shown in Done/complete column action menus, including custom workflow completion lanes; Archive All Done lives in the same menu when available.
|
||||
- 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. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates, edits, and durable selection scoped to real workflow ids. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates and edits scoped to real workflow ids.
|
||||
FNXC:WorkflowSelection 2026-06-30-00:00: The Board view preference persists either a real workflow id or the Board-only All workflows sentinel so refresh/remount restores the operator's last board view without treating the sentinel as a backend workflow id. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:58: All workflows quick-create must use a real workflow intake/default column rather than a synthesized lifecycle column, so custom-default boards do not create tasks into invalid or disappearing columns. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-21:40: Refinement creation from Task Detail and done-task chat must preserve both the source task workflow and the operator's selected Board/List lane, so non-default workflow users do not get bounced back to Coding/default after refinement. -->
|
||||
- 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 and board-hidden 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. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable and is not saved as the durable selected workflow, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow 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. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||
- 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 and board-hidden 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. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, is persisted only as a Board view preference, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow 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. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board also restores the dashboard-only **All workflows** aggregate view when that was the last selected Board view. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
|
||||
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
|
||||
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../components/HeaderWorkflowSwitcherSlot";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
import {
|
||||
filterTasksByGraphWorkflowSelection,
|
||||
GraphWorkflowSwitcherSlot,
|
||||
@@ -212,6 +213,20 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("filters the Board-only all-workflows sentinel out of Header and Graph real-workflow surfaces", async () => {
|
||||
localStorage.setItem("kb:project-cross:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
|
||||
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(screen.queryByText("All workflows")).toBeNull();
|
||||
expect(localStorage.getItem("kb:project-cross:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_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");
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts, type WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
import {
|
||||
ALL_WORKFLOWS_BOARD_VIEW_ID,
|
||||
readBoardWorkflowViewSelection,
|
||||
removeBoardWorkflowSelection,
|
||||
writeBoardWorkflowSelection,
|
||||
} from "../utils/boardWorkflowSelection";
|
||||
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
||||
|
||||
interface BoardProps {
|
||||
@@ -130,7 +136,7 @@ function scheduleDocumentHorizontalScrollReset() {
|
||||
setTimeout(run, 0);
|
||||
}
|
||||
|
||||
export const ALL_WORKFLOWS_BOARD_VIEW_ID = "__all_workflows__";
|
||||
export { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
|
||||
type AggregateBoardColumn = BoardWorkflowColumn & { sourceWorkflowIds: string[] };
|
||||
type AggregateQuickCreateTarget = { columnId: string; workflowId: string };
|
||||
@@ -156,7 +162,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
Board owns one Done sort mode so legacy and built-in workflow Done surfaces stay in sync; the default remains completion-date descending to preserve existing first-load ordering.
|
||||
*/
|
||||
const [doneSortMode, setDoneSortMode] = useState<DoneColumnSortMode>("completion-date-desc");
|
||||
const [isAllWorkflowsViewSelected, setIsAllWorkflowsViewSelected] = useState(false);
|
||||
const [isAllWorkflowsViewSelected, setIsAllWorkflowsViewSelected] = useState(
|
||||
() => readBoardWorkflowViewSelection(projectId) === ALL_WORKFLOWS_BOARD_VIEW_ID,
|
||||
);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
const boardRef = useRef<HTMLElement | null>(null);
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
@@ -398,25 +406,32 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
return counts;
|
||||
}, [boardWorkflows, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAllWorkflowsViewSelected(readBoardWorkflowViewSelection(projectId) === ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
}, [projectId]);
|
||||
|
||||
const handleWorkflowSwitcherChange = useCallback((workflowId: string) => {
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-06-29-16:00:
|
||||
"All workflows" is a Board-only aggregate filter sentinel. Selecting it must not write to the durable workflow-selection store or flow into task creation, workflow editing, or workflow settings APIs that expect a real workflow id.
|
||||
Real workflow selections still use useBoardWorkflows so FN-7234 project-scoped persistence remains the only durable path.
|
||||
FNXC:WorkflowBoard 2026-06-30-00:00:
|
||||
"All workflows" is a Board-only aggregate filter sentinel. It is now persisted in the same project-scoped Board view preference as real workflow ids so refresh/remount restores whichever Board view the operator last selected, while `useBoardWorkflows` filters the sentinel away from shared real-workflow consumers and backend-bound APIs.
|
||||
*/
|
||||
if (workflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) {
|
||||
setIsAllWorkflowsViewSelected(true);
|
||||
writeBoardWorkflowSelection(projectId, ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
return;
|
||||
}
|
||||
setIsAllWorkflowsViewSelected(false);
|
||||
setSelectedWorkflowId(workflowId);
|
||||
}, [setSelectedWorkflowId]);
|
||||
}, [projectId, setSelectedWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowMode) {
|
||||
if (boardWorkflows && !workflowMode) {
|
||||
setIsAllWorkflowsViewSelected(false);
|
||||
if (readBoardWorkflowViewSelection(projectId) === ALL_WORKFLOWS_BOARD_VIEW_ID) {
|
||||
removeBoardWorkflowSelection(projectId);
|
||||
}
|
||||
}
|
||||
}, [workflowMode]);
|
||||
}, [boardWorkflows, projectId, workflowMode]);
|
||||
|
||||
const knownWorkflowIds = useMemo(() => new Set(boardWorkflows?.workflows.map((workflow) => workflow.id) ?? []), [boardWorkflows]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
import type { Task } from "@fusion/core";
|
||||
@@ -1381,7 +1381,7 @@ describe("Board", () => {
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
|
||||
});
|
||||
|
||||
it("selects the all-workflows aggregate view without persisting the sentinel", async () => {
|
||||
it("selects and persists the all-workflows aggregate view", async () => {
|
||||
const projectId = "project-board-all-workflows";
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
@@ -1394,7 +1394,7 @@ describe("Board", () => {
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
});
|
||||
|
||||
await selectWorkflow("__all_workflows__");
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
|
||||
expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows");
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1"));
|
||||
@@ -1409,13 +1409,12 @@ describe("Board", () => {
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-can-drop", "no");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-planning", "yes");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-subtask", "yes");
|
||||
expect(window.localStorage.getItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId))).toBeNull();
|
||||
expect(window.localStorage.getItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId))).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it("passes workflow options and the selected workflow default to per-workflow quick-add", async () => {
|
||||
enableFlag({ "FN-1": CUSTOM_WORKFLOW.id }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "intake" })] });
|
||||
@@ -1446,6 +1445,55 @@ describe("Board", () => {
|
||||
expect(onQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: "__all_workflows__" }));
|
||||
});
|
||||
|
||||
it("restores all-workflows after remount and then persists a real workflow selection", async () => {
|
||||
const projectId = "project-board-all-workflows-remount";
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
const tasks = [mkTask({ id: "FN-1", column: "todo" }), mkTask({ id: "FN-2", column: "intake" })];
|
||||
const first = renderBoard({ projectId, tasks });
|
||||
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows");
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
|
||||
first.unmount();
|
||||
|
||||
renderBoard({ projectId, tasks });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows"));
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1"));
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
|
||||
|
||||
await selectWorkflow("wf-custom");
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Custom Flow"));
|
||||
expect(window.localStorage.getItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId))).toBe("wf-custom");
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
|
||||
expect(screen.queryByTestId("column-todo")).toBeNull();
|
||||
});
|
||||
|
||||
it("scopes persisted all-workflows preferences per project", async () => {
|
||||
const allWorkflowsProjectId = "project-board-all-workflows-alpha";
|
||||
const realWorkflowProjectId = "project-board-all-workflows-beta";
|
||||
window.localStorage.setItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, allWorkflowsProjectId), ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
window.localStorage.setItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, realWorkflowProjectId), "wf-custom");
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
const tasks = [mkTask({ id: "FN-1", column: "todo" }), mkTask({ id: "FN-2", column: "intake" })];
|
||||
const { rerender } = renderBoard({ projectId: allWorkflowsProjectId, tasks });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows"));
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
|
||||
|
||||
rerender(<Board {...createBoardProps({ projectId: realWorkflowProjectId, tasks })} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Custom Flow"));
|
||||
expect(screen.queryByTestId("column-todo")).toBeNull();
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
|
||||
});
|
||||
|
||||
it("falls stale and missing task workflow ids back to the default workflow", async () => {
|
||||
enableFlag(
|
||||
{ "FN-default": "builtin:coding", "FN-stale": "wf-deleted", "FN-custom": "wf-custom" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useBoardWorkflows } from "../useBoardWorkflows";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
|
||||
|
||||
function makePayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
|
||||
return {
|
||||
@@ -141,6 +142,16 @@ describe("useBoardWorkflows", () => {
|
||||
expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not expose the Board-only aggregate sentinel as a selected workflow id", async () => {
|
||||
localStorage.setItem("kb:p1:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
const deps = makeDeps(() => Promise.resolve(makePayload()));
|
||||
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
|
||||
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-a"));
|
||||
expect(result.current.selectedWorkflowId).toBe("wf-a");
|
||||
expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
});
|
||||
|
||||
it("falls back to the default workflow when the selected workflow is deleted", async () => {
|
||||
let payload = makePayload();
|
||||
const deps = makeDeps(() => Promise.resolve(payload));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ALL_WORKFLOWS_BOARD_VIEW_ID,
|
||||
BOARD_WORKFLOW_SELECTION_STORAGE_KEY,
|
||||
readBoardWorkflowSelection,
|
||||
readBoardWorkflowViewSelection,
|
||||
removeBoardWorkflowSelection,
|
||||
writeBoardWorkflowSelection,
|
||||
} from "../boardWorkflowSelection";
|
||||
@@ -31,6 +33,17 @@ describe("boardWorkflowSelection", () => {
|
||||
expect(window.localStorage.getItem(projectKey("project-b"))).toBe("WF-123");
|
||||
expect(readBoardWorkflowSelection("project-a")).toBe("builtin:coding");
|
||||
expect(readBoardWorkflowSelection("project-b")).toBe("WF-123");
|
||||
expect(readBoardWorkflowViewSelection("project-a")).toBe("builtin:coding");
|
||||
});
|
||||
|
||||
it("persists the Board-only all-workflows view without exposing it as a real workflow id", () => {
|
||||
writeBoardWorkflowSelection("project-a", ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
writeBoardWorkflowSelection("project-b", "WF-123");
|
||||
|
||||
expect(window.localStorage.getItem(projectKey("project-a"))).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
expect(readBoardWorkflowViewSelection("project-a")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
expect(readBoardWorkflowSelection("project-a")).toBeNull();
|
||||
expect(readBoardWorkflowSelection("project-b")).toBe("WF-123");
|
||||
});
|
||||
|
||||
it("uses the unscoped default when no project is selected", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "./projectStorage";
|
||||
|
||||
export const BOARD_WORKFLOW_SELECTION_STORAGE_KEY = "kb-dashboard-board-workflow-selection";
|
||||
export const ALL_WORKFLOWS_BOARD_VIEW_ID = "__all_workflows__";
|
||||
|
||||
function isValidWorkflowSelection(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
@@ -15,9 +16,12 @@ function isValidWorkflowSelection(value: unknown): value is string {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Persist the last selected Board workflow view 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.
|
||||
*
|
||||
* FNXC:BoardWorkflowSelection 2026-06-30-00:00:
|
||||
* The durable Board view preference may be either a real workflow id or the Board-only All workflows sentinel so a refresh can restore the aggregate board. Shared real-workflow consumers must call `readBoardWorkflowSelection`; it filters the sentinel out so Header, Graph, List, task creation, and workflow APIs never receive `__all_workflows__` as a backend workflow id.
|
||||
*/
|
||||
export function readBoardWorkflowSelection(projectId?: string): string | null {
|
||||
export function readBoardWorkflowViewSelection(projectId?: string): string | null {
|
||||
try {
|
||||
const stored = getScopedItem(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId);
|
||||
return isValidWorkflowSelection(stored) ? stored.trim() : null;
|
||||
@@ -26,6 +30,11 @@ export function readBoardWorkflowSelection(projectId?: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function readBoardWorkflowSelection(projectId?: string): string | null {
|
||||
const selection = readBoardWorkflowViewSelection(projectId);
|
||||
return selection === ALL_WORKFLOWS_BOARD_VIEW_ID ? null : selection;
|
||||
}
|
||||
|
||||
export function writeBoardWorkflowSelection(projectId: string | undefined, workflowId: string): void {
|
||||
try {
|
||||
const trimmed = workflowId.trim();
|
||||
|
||||
Reference in New Issue
Block a user