diff --git a/.changeset/fn-7057-graph-workflow-filter.md b/.changeset/fn-7057-graph-workflow-filter.md new file mode 100644 index 0000000000..d278a72803 --- /dev/null +++ b/.changeset/fn-7057-graph-workflow-filter.md @@ -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. diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx new file mode 100644 index 0000000000..4e43c0f2ae --- /dev/null +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -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 { + 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(null); + const [headerSelection, setHeaderSelection] = useState(null); + const graphTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, graphSelection); + + return ( + <> +
+ + + {headerSelection?.selectedWorkflow.id ?? "none"} + {graphSelection?.selectedWorkflow.id ?? "none"} +
    + {graphTasks.map((task) => ( +
  • {task.title}
  • + ))} +
+ + ); +} + +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(); + + 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(() => {})); + + render(); + + 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(); + + 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(); + + 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(); + 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(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx index f6f494f66b..b10a047d3c 100644 --- a/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx +++ b/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx @@ -30,9 +30,16 @@ export function filterTasksByGraphWorkflowSelection( 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; }); } diff --git a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx index af76f92774..90b69025c9 100644 --- a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx +++ b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx @@ -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", diff --git a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts index d60efae009..c0b6d6b4e0 100644 --- a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts @@ -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(() => {})); + 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(() => {})); + 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");