FN-7057: preserve workflow selections across dashboard surfaces
Add regression coverage and stale-workflow fallback for workflow-aware dashboard selection. - Cover cached board-workflow hydration across remounted Header and Graph switchers. - Verify Header and Graph selections remain isolated while Graph task filtering follows the Graph selection. - Treat deleted workflow assignments as default-workflow tasks so they remain visible. - Add hook coverage for cache hydration, project changes, selection fallbacks, disabled workflow mode, and consumer isolation. - Add a patch changeset for the stale Graph workflow filtering fix. Files changed: .changeset/fn-7057-graph-workflow-filter.md | 7 + .../workflow-selection-cross-surface.test.tsx | 184 +++++++++++++++++++++ .../app/components/GraphWorkflowSwitcherSlot.tsx | 11 +- .../__tests__/GraphWorkflowSwitcherSlot.test.tsx | 1 + .../app/hooks/__tests__/useBoardWorkflows.test.ts | 133 ++++++++++++++- 5 files changed, 330 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7057 Fusion-Task-Lineage: 52e6682a-576e-4ed6-92d5-03e0e9b5896c
This commit is contained in:
7
.changeset/fn-7057-graph-workflow-filter.md
Normal file
7
.changeset/fn-7057-graph-workflow-filter.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Graph tasks visible when cached workflow assignments reference deleted workflows.
|
||||
category: fix
|
||||
dev: Treat stale Graph `taskWorkflowIds` entries as default-workflow assignments during workflow filtering.
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
|
||||
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../components/HeaderWorkflowSwitcherSlot";
|
||||
import {
|
||||
filterTasksByGraphWorkflowSelection,
|
||||
GraphWorkflowSwitcherSlot,
|
||||
type GraphWorkflowSelection,
|
||||
} from "../components/GraphWorkflowSwitcherSlot";
|
||||
|
||||
const fetchBoardWorkflowsMock = vi.fn();
|
||||
const subscribeSseMock = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../sse-bus", () => ({
|
||||
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
|
||||
}));
|
||||
|
||||
const DEFAULT_WORKFLOW: BoardWorkflowDefinition = {
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [],
|
||||
};
|
||||
|
||||
const GRAPH_WORKFLOW: BoardWorkflowDefinition = {
|
||||
id: "wf-graph",
|
||||
name: "Graph",
|
||||
columns: [],
|
||||
};
|
||||
|
||||
const HEADER_WORKFLOW: BoardWorkflowDefinition = {
|
||||
id: "wf-header",
|
||||
name: "Header",
|
||||
columns: [],
|
||||
};
|
||||
|
||||
const TASKS = [
|
||||
{ id: "FN-default", title: "Default task" },
|
||||
{ id: "FN-unassigned", title: "Unassigned task" },
|
||||
{ id: "FN-graph", title: "Graph task" },
|
||||
{ id: "FN-deleted", title: "Deleted workflow task" },
|
||||
];
|
||||
|
||||
function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
|
||||
return {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: DEFAULT_WORKFLOW.id,
|
||||
workflows: [DEFAULT_WORKFLOW, GRAPH_WORKFLOW, HEADER_WORKFLOW],
|
||||
taskWorkflowIds: {
|
||||
"FN-graph": GRAPH_WORKFLOW.id,
|
||||
"FN-deleted": "wf-deleted",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function CrossSurfaceHarness({ projectId = "project-cross" }: { projectId?: string }) {
|
||||
const [graphSelection, setGraphSelection] = useState<GraphWorkflowSelection | null>(null);
|
||||
const [headerSelection, setHeaderSelection] = useState<HeaderWorkflowSelection | null>(null);
|
||||
const graphTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, graphSelection);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
|
||||
<HeaderWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setHeaderSelection} />
|
||||
<GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setGraphSelection} />
|
||||
<output data-testid="header-selection">{headerSelection?.selectedWorkflow.id ?? "none"}</output>
|
||||
<output data-testid="graph-selection">{graphSelection?.selectedWorkflow.id ?? "none"}</output>
|
||||
<ul data-testid="graph-tasks">
|
||||
{graphTasks.map((task) => (
|
||||
<li key={task.id} data-testid={`graph-task-${task.id}`}>{task.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
subscribeSseMock.mockClear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("workflow selection across dashboard surfaces", () => {
|
||||
it("hydrates remounted surfaces from the persisted board-workflows payload", async () => {
|
||||
const { unmount } = 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);
|
||||
});
|
||||
|
||||
const [, graphSwitcher] = screen.getAllByTestId("workflow-switcher");
|
||||
fireEvent.click(graphSwitcher);
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`));
|
||||
await waitFor(() => expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id));
|
||||
|
||||
unmount();
|
||||
fetchBoardWorkflowsMock.mockImplementation(() => new Promise<BoardWorkflowsPayload>(() => {}));
|
||||
|
||||
render(<CrossSurfaceHarness />);
|
||||
|
||||
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(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross");
|
||||
});
|
||||
|
||||
it("keeps Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => {
|
||||
render(<CrossSurfaceHarness />);
|
||||
|
||||
const switchers = await screen.findAllByTestId("workflow-switcher");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(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();
|
||||
|
||||
fireEvent.click(switchers[1]);
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id);
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(within(graphTasks).getByTestId("graph-task-FN-graph")).toBeInTheDocument();
|
||||
expect(within(graphTasks).queryByTestId("graph-task-FN-default")).toBeNull();
|
||||
expect(within(graphTasks).queryByTestId("graph-task-FN-deleted")).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(switchers[0]);
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${HEADER_WORKFLOW.id}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(HEADER_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id);
|
||||
expect(within(graphTasks).getByTestId("graph-task-FN-graph")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));
|
||||
const { unmount } = render(<CrossSurfaceHarness />);
|
||||
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
for (const task of TASKS) {
|
||||
expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
unmount();
|
||||
sessionStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] }));
|
||||
const empty = render(<CrossSurfaceHarness />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
empty.unmount();
|
||||
|
||||
sessionStorage.clear();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [DEFAULT_WORKFLOW] }));
|
||||
render(<CrossSurfaceHarness />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -30,9 +30,16 @@ export function filterTasksByGraphWorkflowSelection<T extends { id: string }>(
|
||||
selection: GraphWorkflowSelection | null,
|
||||
): T[] {
|
||||
if (!projectId || !selection) return tasks;
|
||||
const workflowIds = new Set(selection.boardWorkflows.workflows.map((workflow) => workflow.id));
|
||||
return tasks.filter((task) => {
|
||||
const assignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id]
|
||||
?? selection.boardWorkflows.defaultWorkflowId;
|
||||
const rawAssignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id];
|
||||
/*
|
||||
FNXC:GraphWorkflowSelection 2026-06-26-03:48:
|
||||
Graph task scoping treats stale taskWorkflowIds entries that reference deleted workflows as default-workflow assignments. The board-workflows payload can outlive workflow deletion across cache/remount boundaries, so filtering must not hide those tasks from every workflow view.
|
||||
*/
|
||||
const assignedWorkflowId = rawAssignedWorkflowId && workflowIds.has(rawAssignedWorkflowId)
|
||||
? rawAssignedWorkflowId
|
||||
: selection.boardWorkflows.defaultWorkflowId;
|
||||
return assignedWorkflowId === selection.selectedWorkflow.id;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("filterTasksByGraphWorkflowSelection", () => {
|
||||
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection).map((task) => task.id)).toEqual([
|
||||
"FN-default",
|
||||
"FN-unassigned",
|
||||
"FN-unknown",
|
||||
]);
|
||||
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", { ...selection, selectedWorkflow: CUSTOM_WORKFLOW }).map((task) => task.id)).toEqual([
|
||||
"FN-review",
|
||||
|
||||
@@ -37,8 +37,9 @@ describe("useBoardWorkflows", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("initial fetch populates workflow options and selects the default", async () => {
|
||||
const deps = makeDeps(() => Promise.resolve(makePayload()));
|
||||
it("initial fetch populates workflow options, writes cache, and selects the default", async () => {
|
||||
const payload = makePayload();
|
||||
const deps = makeDeps(() => Promise.resolve(payload));
|
||||
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
|
||||
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
|
||||
@@ -47,7 +48,56 @@ describe("useBoardWorkflows", () => {
|
||||
// Default sorts first.
|
||||
expect(result.current.workflowOptions[0].id).toBe("wf-a");
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-a");
|
||||
expect(deps.writeBoardWorkflowsCache).toHaveBeenCalledWith("p1", expect.objectContaining({ flagEnabled: true }));
|
||||
expect(deps.writeBoardWorkflowsCache).toHaveBeenCalledWith("p1", payload);
|
||||
});
|
||||
|
||||
it("hydrates board workflows synchronously from cache before refetch resolves", () => {
|
||||
const cachedPayload = makePayload({
|
||||
defaultWorkflowId: "wf-b",
|
||||
workflows: [
|
||||
{ id: "wf-a", name: "Alpha", columns: [] },
|
||||
{ id: "wf-b", name: "Beta", columns: [] },
|
||||
],
|
||||
});
|
||||
const deps = makeDeps(() => new Promise<BoardWorkflowsPayload>(() => {}));
|
||||
deps.readBoardWorkflowsCache.mockReturnValue(cachedPayload);
|
||||
|
||||
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
|
||||
expect(deps.readBoardWorkflowsCache).toHaveBeenCalledWith("p1");
|
||||
expect(result.current.boardWorkflows).toEqual(cachedPayload);
|
||||
expect(result.current.workflowOptions.map((workflow) => workflow.id)).toEqual(["wf-b", "wf-a"]);
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-b");
|
||||
expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-hydrates per-project cache entries when the project changes", async () => {
|
||||
const projectOnePayload = makePayload({ defaultWorkflowId: "wf-a" });
|
||||
const projectTwoPayload = makePayload({
|
||||
defaultWorkflowId: "wf-c",
|
||||
workflows: [{ id: "wf-c", name: "Gamma", columns: [] }],
|
||||
});
|
||||
const deps = makeDeps(() => new Promise<BoardWorkflowsPayload>(() => {}));
|
||||
deps.readBoardWorkflowsCache.mockImplementation((projectId?: string) => {
|
||||
if (projectId === "p1") return projectOnePayload;
|
||||
if (projectId === "p2") return projectTwoPayload;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useBoardWorkflows({ projectId, ...deps }),
|
||||
{ initialProps: { projectId: "p1" } },
|
||||
);
|
||||
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-a");
|
||||
|
||||
rerender({ projectId: "p2" });
|
||||
|
||||
await waitFor(() => expect(result.current.boardWorkflows).toEqual(projectTwoPayload));
|
||||
expect(result.current.workflowOptions.map((workflow) => workflow.id)).toEqual(["wf-c"]);
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-c");
|
||||
expect(deps.readBoardWorkflowsCache).toHaveBeenCalledWith("p1");
|
||||
expect(deps.readBoardWorkflowsCache).toHaveBeenCalledWith("p2");
|
||||
});
|
||||
|
||||
it("stale-response guard drops an out-of-order response", async () => {
|
||||
@@ -80,7 +130,7 @@ describe("useBoardWorkflows", () => {
|
||||
|
||||
it("an SSE workflow event re-fetches", async () => {
|
||||
const deps = makeDeps(() => Promise.resolve(makePayload()));
|
||||
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
|
||||
|
||||
await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
|
||||
expect(typeof subscribeHandlers["workflow:updated"]).toBe("function");
|
||||
@@ -89,6 +139,81 @@ describe("useBoardWorkflows", () => {
|
||||
expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to the default workflow when the selected workflow is deleted", async () => {
|
||||
let payload = makePayload();
|
||||
const deps = makeDeps(() => Promise.resolve(payload));
|
||||
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"));
|
||||
|
||||
payload = makePayload({ workflows: [{ id: "wf-a", name: "Alpha", columns: [] }] });
|
||||
await act(async () => { result.current.refreshBoardWorkflows(); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-a");
|
||||
expect(result.current.selectedWorkflowId).toBe("wf-a");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the first workflow when the default workflow is absent", async () => {
|
||||
let payload = makePayload();
|
||||
const deps = makeDeps(() => Promise.resolve(payload));
|
||||
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"));
|
||||
|
||||
payload = makePayload({
|
||||
defaultWorkflowId: "wf-missing",
|
||||
workflows: [{ id: "wf-c", name: "Gamma", columns: [] }],
|
||||
});
|
||||
await act(async () => { result.current.refreshBoardWorkflows(); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedWorkflow?.id).toBe("wf-c");
|
||||
expect(result.current.selectedWorkflowId).toBe("wf-c");
|
||||
});
|
||||
});
|
||||
|
||||
it("resets selection when workflow mode turns off", async () => {
|
||||
let payload = makePayload();
|
||||
const deps = makeDeps(() => Promise.resolve(payload));
|
||||
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.selectedWorkflowId).toBe("wf-b"));
|
||||
|
||||
payload = makePayload({ flagEnabled: false, workflows: [] });
|
||||
await act(async () => { result.current.refreshBoardWorkflows(); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workflowMode).toBe(false);
|
||||
expect(result.current.selectedWorkflow).toBeNull();
|
||||
expect(result.current.selectedWorkflowId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps selected workflow state isolated per hook consumer", async () => {
|
||||
const depsOne = makeDeps(() => Promise.resolve(makePayload()));
|
||||
const depsTwo = makeDeps(() => Promise.resolve(makePayload()));
|
||||
|
||||
const first = renderHook(() => useBoardWorkflows({ projectId: "p1", ...depsOne }));
|
||||
const second = renderHook(() => useBoardWorkflows({ projectId: "p1", ...depsTwo }));
|
||||
|
||||
await waitFor(() => expect(first.result.current.selectedWorkflow?.id).toBe("wf-a"));
|
||||
await waitFor(() => expect(second.result.current.selectedWorkflow?.id).toBe("wf-a"));
|
||||
|
||||
act(() => { first.result.current.setSelectedWorkflowId("wf-b"); });
|
||||
|
||||
await waitFor(() => expect(first.result.current.selectedWorkflow?.id).toBe("wf-b"));
|
||||
expect(second.result.current.selectedWorkflow?.id).toBe("wf-a");
|
||||
expect(second.result.current.selectedWorkflowId).toBe("wf-a");
|
||||
});
|
||||
|
||||
it("unmount removes visibility/focus listeners and unsubscribes from SSE", async () => {
|
||||
const addSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
Reference in New Issue
Block a user