FN-6776: prevent workflow board legacy flash
Prevent workflow-enabled board surfaces from flashing the legacy layout during first paint. - Hydrate Board and ListView workflow metadata from a per-project session cache before async revalidation. - Gate legacy board/list rendering behind settings and workflow-lane metadata readiness, with neutral skeleton states for cold or empty workflow loads. - Add regression coverage for no-legacy-flash behavior and cache validation, plus docs and changeset notes. Files changed: .changeset/fn-6776-board-flash.md | 5 + docs/dashboard-guide.md | 1 + packages/dashboard/app/App.tsx | 4 + packages/dashboard/app/components/Board.css | 69 +++++ packages/dashboard/app/components/Board.tsx | 53 +++- packages/dashboard/app/components/ListView.css | 53 ++++ packages/dashboard/app/components/ListView.tsx | 52 +++- .../__tests__/board-no-legacy-flash.test.tsx | 326 +++++++++++++++++++++ .../utils/__tests__/boardWorkflowsCache.test.ts | 71 +++++ .../dashboard/app/utils/boardWorkflowsCache.ts | 49 ++++ 10 files changed, 676 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6776 Fusion-Task-Lineage: caa8e53f-9b6d-49a6-94ca-b694550b43ef
This commit is contained in:
5
.changeset/fn-6776-board-flash.md
Normal file
5
.changeset/fn-6776-board-flash.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Eliminate the legacy board flash before workflow lanes load by caching per-project board workflow metadata and showing a neutral skeleton while metadata resolves.
|
||||||
@@ -78,6 +78,7 @@ Features:
|
|||||||
- 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)
|
- 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.
|
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
|
||||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns.
|
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns.
|
||||||
|
- 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1849,6 +1849,8 @@ function AppInner() {
|
|||||||
prAuthAvailable={prAuthAvailable}
|
prAuthAvailable={prAuthAvailable}
|
||||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||||
|
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||||
|
settingsLoaded={settingsLoaded}
|
||||||
/>
|
/>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
);
|
);
|
||||||
@@ -1887,6 +1889,8 @@ function AppInner() {
|
|||||||
prAuthAvailable={prAuthAvailable}
|
prAuthAvailable={prAuthAvailable}
|
||||||
autoMerge={autoMerge}
|
autoMerge={autoMerge}
|
||||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||||
|
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||||
|
settingsLoaded={settingsLoaded}
|
||||||
/>
|
/>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
69
packages/dashboard/app/components/Board.css
Normal file
69
packages/dashboard/app/components/Board.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
FNXC:BoardWorkflows 2026-06-20-09:02:
|
||||||
|
The board needs a neutral first-paint shell whenever workflow lanes are enabled but metadata is not ready, so users never see the legacy single-lane board flash before workflow lanes load.
|
||||||
|
*/
|
||||||
|
.board.board-workflows-skeleton {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--column-gap, var(--space-md));
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: var(--board-padding, var(--space-md));
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-workflows-skeleton__column {
|
||||||
|
flex: 1 0 min(24rem, 85vw);
|
||||||
|
min-width: min(24rem, 85vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-workflows-skeleton__header,
|
||||||
|
.board-workflows-skeleton__card {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--border) 70%, var(--surface));
|
||||||
|
animation: board-workflows-skeleton-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-workflows-skeleton__header {
|
||||||
|
block-size: var(--space-lg);
|
||||||
|
inline-size: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-workflows-skeleton__card {
|
||||||
|
block-size: calc(var(--space-2xl) * 2);
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-workflows-skeleton__card--short {
|
||||||
|
inline-size: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes board-workflows-skeleton-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.board.board-workflows-skeleton {
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-workflows-skeleton__column {
|
||||||
|
flex-basis: 86vw;
|
||||||
|
min-width: 86vw;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
|||||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||||
import { Column } from "./Column";
|
import { Column } from "./Column";
|
||||||
import "./Lane.css";
|
import "./Lane.css";
|
||||||
|
import "./Board.css";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,6 +16,7 @@ import { subscribeSse } from "../sse-bus";
|
|||||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||||
|
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||||
|
|
||||||
interface BoardProps {
|
interface BoardProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -74,6 +76,10 @@ interface BoardProps {
|
|||||||
onOpenWorkflowEditor?: (workflowId?: string) => void;
|
onOpenWorkflowEditor?: (workflowId?: string) => void;
|
||||||
/** Opens the workflow editor to create a new workflow. */
|
/** Opens the workflow editor to create a new workflow. */
|
||||||
onCreateWorkflow?: () => void;
|
onCreateWorkflow?: () => void;
|
||||||
|
/** Already-resolved app setting for whether workflow lanes should be used. */
|
||||||
|
workflowColumnsEnabled?: boolean;
|
||||||
|
/** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */
|
||||||
|
settingsLoaded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +130,21 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }: BoardProps) {
|
function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
|
||||||
|
return (
|
||||||
|
<main className="board board-workflows-skeleton" id="board" aria-busy={!empty} aria-label={empty ? "No workflow lanes available" : "Loading workflow lanes"} data-testid={empty ? "board-workflows-empty" : "board-workflows-skeleton"}>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<section className="board-workflows-skeleton__column card" key={index} aria-hidden="true">
|
||||||
|
<div className="board-workflows-skeleton__header" />
|
||||||
|
<div className="board-workflows-skeleton__card" />
|
||||||
|
<div className="board-workflows-skeleton__card board-workflows-skeleton__card--short" />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const archivedLoadedRef = useRef(false);
|
const archivedLoadedRef = useRef(false);
|
||||||
@@ -330,9 +350,18 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── U9 multi-lane board (flag-gated) ──────────────────────────────────────
|
// ── U9 multi-lane board (flag-gated) ──────────────────────────────────────
|
||||||
|
/*
|
||||||
|
FNXC:BoardWorkflows 2026-06-20-08:58:
|
||||||
|
Workflow-columns-enabled users must never see the legacy single-lane board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while settings or uncached workflow metadata are unknown.
|
||||||
|
*/
|
||||||
// Fetch board-workflows metadata. When the flag is OFF the server returns
|
// Fetch board-workflows metadata. When the flag is OFF the server returns
|
||||||
// { flagEnabled: false } and we render the legacy single-lane board below.
|
// { flagEnabled: false } and we render the legacy single-lane board below.
|
||||||
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
|
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 [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||||
const draggingTaskIdRef = useRef<string | null>(null);
|
const draggingTaskIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -344,16 +373,24 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
// when a definition's lanes / column traits change. The visibility/focus
|
// when a definition's lanes / column traits change. The visibility/focus
|
||||||
// refetch below is retained as a stopgap for missed events / reconnects.
|
// refetch below is retained as a stopgap for missed events / reconnects.
|
||||||
const boardWorkflowsFetchSeqRef = useRef(0);
|
const boardWorkflowsFetchSeqRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
|
||||||
|
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||||
|
}, [projectId, shouldHydrateBoardWorkflowsCache]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runFetch = () => {
|
const runFetch = () => {
|
||||||
const seq = ++boardWorkflowsFetchSeqRef.current;
|
const seq = ++boardWorkflowsFetchSeqRef.current;
|
||||||
fetchBoardWorkflows(projectId)
|
fetchBoardWorkflows(projectId)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
|
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||||
|
setBoardWorkflowsState({ projectId, payload });
|
||||||
|
writeBoardWorkflowsCache(projectId, payload);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||||
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
|
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -509,6 +546,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
|
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
|
||||||
// messages. We do NOT eagerly call `/api/github/batch-status` on board load.
|
// messages. We do NOT eagerly call `/api/github/batch-status` on board load.
|
||||||
|
|
||||||
|
const shouldGateLegacyBoard = boardWorkflows === null
|
||||||
|
? (workflowColumnsEnabled === true || settingsLoaded === false)
|
||||||
|
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
|
||||||
|
|
||||||
|
if (shouldGateLegacyBoard) {
|
||||||
|
return <BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (workflowMode && selectedWorkflow) {
|
if (workflowMode && selectedWorkflow) {
|
||||||
return (
|
return (
|
||||||
<div className="board-workflow-view">
|
<div className="board-workflow-view">
|
||||||
|
|||||||
@@ -1139,3 +1139,56 @@ FN-6529 requires list-view agent-active tasks to use a simple static highlight i
|
|||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BoardWorkflows 2026-06-20-09:12:
|
||||||
|
ListView needs a neutral workflow-loading shell under the same flag-aware gate as Board, avoiding a first-paint fallback to legacy list columns while workflow metadata revalidates.
|
||||||
|
*/
|
||||||
|
.list-view--workflow-skeleton {
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-workflow-skeleton {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-workflow-skeleton__row {
|
||||||
|
block-size: var(--space-xl);
|
||||||
|
inline-size: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--border) 70%, var(--surface));
|
||||||
|
animation: list-workflow-skeleton-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-workflow-skeleton__row--header {
|
||||||
|
inline-size: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-workflow-skeleton__row--short {
|
||||||
|
inline-size: 78%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes list-workflow-skeleton-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.list-view--workflow-skeleton {
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-workflow-skeleton {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ".
|
|||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||||
|
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||||
|
|
||||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||||
triage: "var(--triage)",
|
triage: "var(--triage)",
|
||||||
@@ -241,6 +242,8 @@ interface ListViewProps {
|
|||||||
prAuthAvailable?: boolean;
|
prAuthAvailable?: boolean;
|
||||||
autoMerge?: boolean;
|
autoMerge?: boolean;
|
||||||
onCreateWorkflow?: () => void;
|
onCreateWorkflow?: () => void;
|
||||||
|
workflowColumnsEnabled?: boolean;
|
||||||
|
settingsLoaded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
|
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
|
||||||
@@ -305,6 +308,8 @@ export function ListView({
|
|||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
autoMerge,
|
autoMerge,
|
||||||
onCreateWorkflow,
|
onCreateWorkflow,
|
||||||
|
workflowColumnsEnabled,
|
||||||
|
settingsLoaded,
|
||||||
}: ListViewProps) {
|
}: ListViewProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const columnLabel = useColumnLabel();
|
const columnLabel = useColumnLabel();
|
||||||
@@ -313,7 +318,16 @@ export function ListView({
|
|||||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
|
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
|
||||||
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
|
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
|
||||||
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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 [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||||
const viewportMode = useViewportMode();
|
const viewportMode = useViewportMode();
|
||||||
const isMobile = viewportMode === "mobile";
|
const isMobile = viewportMode === "mobile";
|
||||||
@@ -393,16 +407,24 @@ export function ListView({
|
|||||||
setSidebarWidth(readSidebarWidth(projectId));
|
setSidebarWidth(readSidebarWidth(projectId));
|
||||||
}, [projectId, tasks]);
|
}, [projectId, tasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
|
||||||
|
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||||
|
}, [projectId, shouldHydrateBoardWorkflowsCache]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runFetch = () => {
|
const runFetch = () => {
|
||||||
const seq = ++boardWorkflowsFetchSeqRef.current;
|
const seq = ++boardWorkflowsFetchSeqRef.current;
|
||||||
fetchBoardWorkflows(projectId)
|
fetchBoardWorkflows(projectId)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
|
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||||
|
setBoardWorkflowsState({ projectId, payload });
|
||||||
|
writeBoardWorkflowsCache(projectId, payload);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||||
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
|
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1689,6 +1711,22 @@ export function ListView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderListWorkflowSkeleton = (empty = false) => (
|
||||||
|
<div className="list-view list-view--workflow-skeleton" aria-busy={!empty} aria-label={empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")} data-testid={empty ? "list-workflows-empty" : "list-workflows-skeleton"}>
|
||||||
|
<div className="list-view-header">
|
||||||
|
<div>
|
||||||
|
<h2>{t("listView.title", "List View")}</h2>
|
||||||
|
<p className="list-subtitle">{empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="list-workflow-skeleton card" aria-hidden="true">
|
||||||
|
<div className="list-workflow-skeleton__row list-workflow-skeleton__row--header" />
|
||||||
|
<div className="list-workflow-skeleton__row" />
|
||||||
|
<div className="list-workflow-skeleton__row list-workflow-skeleton__row--short" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderBulkEditToolbars = () => (
|
const renderBulkEditToolbars = () => (
|
||||||
<>
|
<>
|
||||||
<div className="bulk-edit-toolbar">
|
<div className="bulk-edit-toolbar">
|
||||||
@@ -1770,6 +1808,14 @@ export function ListView({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shouldGateLegacyList = boardWorkflows === null
|
||||||
|
? (workflowColumnsEnabled === true || settingsLoaded === false)
|
||||||
|
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
|
||||||
|
|
||||||
|
if (shouldGateLegacyList) {
|
||||||
|
return renderListWorkflowSkeleton(boardWorkflows?.flagEnabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list-view">
|
<div className="list-view">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { Board } from "../Board";
|
||||||
|
import { ListView } from "../ListView";
|
||||||
|
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
|
||||||
|
import type { BoardWorkflowsPayload } from "../../api";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
fetchBoardWorkflows: vi.fn(),
|
||||||
|
fetchWorkflowSteps: vi.fn(),
|
||||||
|
fetchNodes: vi.fn(),
|
||||||
|
fetchTaskDetail: vi.fn(),
|
||||||
|
batchUpdateTaskModels: vi.fn(),
|
||||||
|
promoteTask: vi.fn(),
|
||||||
|
fetchModels: vi.fn(),
|
||||||
|
fetchSettings: vi.fn(),
|
||||||
|
fetchGlobalSettings: vi.fn(),
|
||||||
|
api: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchBoardWorkflows: apiMocks.fetchBoardWorkflows,
|
||||||
|
fetchWorkflowSteps: apiMocks.fetchWorkflowSteps,
|
||||||
|
fetchNodes: apiMocks.fetchNodes,
|
||||||
|
fetchTaskDetail: apiMocks.fetchTaskDetail,
|
||||||
|
batchUpdateTaskModels: apiMocks.batchUpdateTaskModels,
|
||||||
|
promoteTask: apiMocks.promoteTask,
|
||||||
|
fetchModels: apiMocks.fetchModels,
|
||||||
|
fetchSettings: apiMocks.fetchSettings,
|
||||||
|
fetchGlobalSettings: apiMocks.fetchGlobalSettings,
|
||||||
|
api: apiMocks.api,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useBlockerFanout", () => ({
|
||||||
|
useBlockerFanout: () => new Map(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../sse-bus", () => ({
|
||||||
|
subscribeSse: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../Column", () => ({
|
||||||
|
Column: React.memo(({ column, workflowMode }: { column: string; workflowMode?: boolean }) => (
|
||||||
|
<div className="column" data-testid={`column-${column}`} data-workflow-mode={workflowMode ? "true" : "false"} />
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workflowPayload: BoardWorkflowsPayload = {
|
||||||
|
flagEnabled: true,
|
||||||
|
defaultWorkflowId: "workflow-a",
|
||||||
|
workflows: [
|
||||||
|
{
|
||||||
|
id: "workflow-a",
|
||||||
|
name: "Workflow A",
|
||||||
|
columns: [
|
||||||
|
{ id: "todo", name: "Todo", flags: { intake: true } },
|
||||||
|
{ id: "done", name: "Done", flags: { complete: true } },
|
||||||
|
{ id: "archived", name: "Archived", flags: { archived: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workflow-b",
|
||||||
|
name: "Workflow B",
|
||||||
|
columns: [
|
||||||
|
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||||
|
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
taskWorkflowIds: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyWorkflowPayload: BoardWorkflowsPayload = {
|
||||||
|
flagEnabled: true,
|
||||||
|
defaultWorkflowId: "workflow-a",
|
||||||
|
workflows: [],
|
||||||
|
taskWorkflowIds: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const flagOffPayload: BoardWorkflowsPayload = {
|
||||||
|
flagEnabled: false,
|
||||||
|
defaultWorkflowId: "builtin:coding",
|
||||||
|
workflows: [],
|
||||||
|
taskWorkflowIds: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockViewport(width: number) {
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: query.includes("768px") ? width <= 768 : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
|
||||||
|
const boardProps = {
|
||||||
|
tasks,
|
||||||
|
maxConcurrent: 2,
|
||||||
|
onMoveTask: vi.fn(async () => ({} as Task)),
|
||||||
|
onOpenDetail: vi.fn(),
|
||||||
|
addToast: vi.fn(),
|
||||||
|
onNewTask: vi.fn(),
|
||||||
|
autoMerge: true,
|
||||||
|
onToggleAutoMerge: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const listProps = {
|
||||||
|
tasks,
|
||||||
|
onMoveTask: vi.fn(async () => ({} as Task)),
|
||||||
|
onDeleteTask: vi.fn(async () => ({} as Task)),
|
||||||
|
onMergeTask: vi.fn(async () => ({} as never)),
|
||||||
|
onOpenDetail: vi.fn(),
|
||||||
|
addToast: vi.fn(),
|
||||||
|
onCreateWorkflow: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
type Surface = "Board" | "ListView";
|
||||||
|
type Breakpoint = "desktop" | "mobile";
|
||||||
|
|
||||||
|
function renderSurface(surface: Surface, projectId = "project-a") {
|
||||||
|
if (surface === "Board") {
|
||||||
|
return render(<Board {...boardProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
|
||||||
|
}
|
||||||
|
return render(<ListView {...listProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectWorkflowLayout(surface: Surface) {
|
||||||
|
if (surface === "Board") {
|
||||||
|
expect(document.querySelector(".board-workflow-columns")).not.toBeNull();
|
||||||
|
expect(document.querySelector(".board-workflows-skeleton")).toBeNull();
|
||||||
|
expect(document.querySelectorAll('.column[data-workflow-mode="true"]').length).toBeGreaterThan(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByTestId("workflow-switcher")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectLegacyLayout(surface: Surface) {
|
||||||
|
if (surface === "Board") {
|
||||||
|
expect(document.querySelector(".board-workflow-columns")).toBeNull();
|
||||||
|
expect(document.querySelector(".board-workflows-skeleton")).toBeNull();
|
||||||
|
expect(document.querySelectorAll('.column[data-workflow-mode="false"]').length).toBeGreaterThan(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||||
|
expect(screen.getByTestId("list-split-layout")).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSkeleton(surface: Surface, empty = false) {
|
||||||
|
if (surface === "Board") {
|
||||||
|
expect(screen.getByTestId(empty ? "board-workflows-empty" : "board-workflows-skeleton")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".board-workflow-columns")).toBeNull();
|
||||||
|
expect(document.querySelectorAll(".column")).toHaveLength(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByTestId(empty ? "list-workflows-empty" : "list-workflows-skeleton")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("no legacy-board flash before workflow lanes load (FN-6776)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMocks.fetchWorkflowSteps.mockResolvedValue([]);
|
||||||
|
apiMocks.fetchNodes.mockResolvedValue([]);
|
||||||
|
apiMocks.fetchTaskDetail.mockResolvedValue(null);
|
||||||
|
apiMocks.promoteTask.mockResolvedValue({});
|
||||||
|
apiMocks.fetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||||
|
apiMocks.fetchSettings.mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} });
|
||||||
|
apiMocks.fetchGlobalSettings.mockResolvedValue({});
|
||||||
|
apiMocks.api.mockResolvedValue({ sessions: [] });
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Surface>(["Board", "ListView"])("%s renders legacy immediately when workflowColumns is known off", (surface) => {
|
||||||
|
mockViewport(1024);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
if (surface === "Board") {
|
||||||
|
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
|
||||||
|
} else {
|
||||||
|
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
|
||||||
|
}
|
||||||
|
|
||||||
|
expectLegacyLayout(surface);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[Surface, Breakpoint]>([
|
||||||
|
["Board", "desktop"],
|
||||||
|
["Board", "mobile"],
|
||||||
|
["ListView", "desktop"],
|
||||||
|
["ListView", "mobile"],
|
||||||
|
])("%s at %s renders skeleton, not legacy, while uncached workflow payload is pending", async (surface, breakpoint) => {
|
||||||
|
mockViewport(breakpoint === "mobile" ? 390 : 1200);
|
||||||
|
const deferred = createDeferred<BoardWorkflowsPayload>();
|
||||||
|
apiMocks.fetchBoardWorkflows.mockReturnValue(deferred.promise);
|
||||||
|
|
||||||
|
renderSurface(surface);
|
||||||
|
|
||||||
|
expectSkeleton(surface);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
deferred.resolve(workflowPayload);
|
||||||
|
await deferred.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expectWorkflowLayout(surface));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[Surface, Breakpoint]>([
|
||||||
|
["Board", "desktop"],
|
||||||
|
["Board", "mobile"],
|
||||||
|
["ListView", "desktop"],
|
||||||
|
["ListView", "mobile"],
|
||||||
|
])("%s at %s renders cached workflow lanes on first paint", (surface, breakpoint) => {
|
||||||
|
mockViewport(breakpoint === "mobile" ? 390 : 1200);
|
||||||
|
writeBoardWorkflowsCache("project-a", workflowPayload);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
renderSurface(surface);
|
||||||
|
|
||||||
|
expectWorkflowLayout(surface);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Surface>(["Board", "ListView"])("%s keeps legacy hidden when the enabled payload has no workflows", async (surface) => {
|
||||||
|
mockViewport(1024);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockResolvedValue(emptyWorkflowPayload);
|
||||||
|
|
||||||
|
renderSurface(surface);
|
||||||
|
|
||||||
|
await waitFor(() => expectSkeleton(surface, true));
|
||||||
|
expectLegacyLayoutHiddenForEmpty(surface);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Surface>(["Board", "ListView"])("%s renders skeleton while settings are not loaded", (surface) => {
|
||||||
|
mockViewport(1024);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
if (surface === "Board") {
|
||||||
|
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
|
||||||
|
} else {
|
||||||
|
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
expectSkeleton(surface);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Surface>(["Board", "ListView"])("%s fetch error exits the skeleton to a terminal legacy layout", async (surface) => {
|
||||||
|
mockViewport(1024);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockRejectedValue(new Error("network"));
|
||||||
|
|
||||||
|
renderSurface(surface);
|
||||||
|
expectSkeleton(surface);
|
||||||
|
|
||||||
|
await waitFor(() => expectLegacyLayout(surface));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Surface>(["Board", "ListView"])("%s does not leak cached workflow layouts across project switches", async (surface) => {
|
||||||
|
mockViewport(1024);
|
||||||
|
writeBoardWorkflowsCache("project-a", workflowPayload);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
const view = renderSurface(surface, "project-a");
|
||||||
|
expectWorkflowLayout(surface);
|
||||||
|
|
||||||
|
if (surface === "Board") {
|
||||||
|
view.rerender(<Board {...boardProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
|
||||||
|
} else {
|
||||||
|
view.rerender(<ListView {...listProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
|
||||||
|
}
|
||||||
|
|
||||||
|
expectSkeleton(surface);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Surface>(["Board", "ListView"])("%s ignores another project's cache when flag-off payload is cached locally", (surface) => {
|
||||||
|
mockViewport(1024);
|
||||||
|
writeBoardWorkflowsCache("project-b", workflowPayload);
|
||||||
|
writeBoardWorkflowsCache("project-a", flagOffPayload);
|
||||||
|
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
renderSurface(surface, "project-a");
|
||||||
|
|
||||||
|
expectLegacyLayout(surface);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectLegacyLayoutHiddenForEmpty(surface: Surface) {
|
||||||
|
if (surface === "Board") {
|
||||||
|
expect(document.querySelector(".board-workflow-columns")).toBeNull();
|
||||||
|
expect(document.querySelectorAll(".column")).toHaveLength(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { BoardWorkflowsPayload } from "../../api";
|
||||||
|
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../boardWorkflowsCache";
|
||||||
|
|
||||||
|
const payload: BoardWorkflowsPayload = {
|
||||||
|
flagEnabled: true,
|
||||||
|
defaultWorkflowId: "builtin:coding",
|
||||||
|
workflows: [
|
||||||
|
{
|
||||||
|
id: "builtin:coding",
|
||||||
|
name: "Coding",
|
||||||
|
columns: [{ id: "todo", name: "Todo", flags: {} }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
taskWorkflowIds: { "FN-1": "builtin:coding" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("boardWorkflowsCache", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
}
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips board-workflows payloads per project", () => {
|
||||||
|
writeBoardWorkflowsCache("project-a", payload);
|
||||||
|
|
||||||
|
expect(readBoardWorkflowsCache("project-a")).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps project cache keys isolated", () => {
|
||||||
|
writeBoardWorkflowsCache("project-a", payload);
|
||||||
|
|
||||||
|
expect(readBoardWorkflowsCache("project-b")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for missing, corrupt, or invalid entries", () => {
|
||||||
|
expect(readBoardWorkflowsCache("missing")).toBeNull();
|
||||||
|
|
||||||
|
window.sessionStorage.setItem("fusion:board-workflows:corrupt", "{");
|
||||||
|
expect(readBoardWorkflowsCache("corrupt")).toBeNull();
|
||||||
|
|
||||||
|
window.sessionStorage.setItem("fusion:board-workflows:invalid", JSON.stringify({ flagEnabled: true, workflows: {} }));
|
||||||
|
expect(readBoardWorkflowsCache("invalid")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows sessionStorage write failures", () => {
|
||||||
|
vi.spyOn(window.sessionStorage.__proto__, "setItem").mockImplementation(() => {
|
||||||
|
throw new Error("quota");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => writeBoardWorkflowsCache("project-a", payload)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows sessionStorage read failures", () => {
|
||||||
|
vi.spyOn(window.sessionStorage.__proto__, "getItem").mockImplementation(() => {
|
||||||
|
throw new Error("private mode");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readBoardWorkflowsCache("project-a")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without window for SSR callers", () => {
|
||||||
|
vi.stubGlobal("window", undefined);
|
||||||
|
|
||||||
|
expect(readBoardWorkflowsCache("project-a")).toBeNull();
|
||||||
|
expect(() => writeBoardWorkflowsCache("project-a", payload)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/dashboard/app/utils/boardWorkflowsCache.ts
Normal file
49
packages/dashboard/app/utils/boardWorkflowsCache.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BoardWorkflowsPayload } from "../api";
|
||||||
|
|
||||||
|
const BOARD_WORKFLOWS_CACHE_PREFIX = "fusion:board-workflows:";
|
||||||
|
const DEFAULT_PROJECT_CACHE_KEY = "default";
|
||||||
|
|
||||||
|
function cacheKey(projectId?: string): string {
|
||||||
|
return `${BOARD_WORKFLOWS_CACHE_PREFIX}${projectId ?? DEFAULT_PROJECT_CACHE_KEY}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardWorkflowsPayload(value: unknown): value is BoardWorkflowsPayload {
|
||||||
|
if (!isRecord(value)) return false;
|
||||||
|
if (typeof value.flagEnabled !== "boolean") return false;
|
||||||
|
if (!Array.isArray(value.workflows)) return false;
|
||||||
|
if (typeof value.defaultWorkflowId !== "string") return false;
|
||||||
|
if (!isRecord(value.taskWorkflowIds)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:BoardWorkflows 2026-06-20-08:50:
|
||||||
|
* Cache the last successful board-workflows payload per project in sessionStorage so Board and ListView can render the correct workflow-lane layout immediately on remount and never flash the legacy single-lane board before the async revalidation finishes.
|
||||||
|
*/
|
||||||
|
export function readBoardWorkflowsCache(projectId?: string): BoardWorkflowsPayload | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(cacheKey(projectId));
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return isBoardWorkflowsPayload(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeBoardWorkflowsCache(projectId: string | undefined, payload: BoardWorkflowsPayload): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(cacheKey(projectId), JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// Private-mode/quota failures should never prevent board rendering.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user