diff --git a/.changeset/fn-6941-graph-workflow-dropdown.md b/.changeset/fn-6941-graph-workflow-dropdown.md new file mode 100644 index 0000000000..c55a5d5803 --- /dev/null +++ b/.changeset/fn-6941-graph-workflow-dropdown.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a workflow dropdown to filter tasks in the dependency Graph view. +category: feature +dev: Scopes plugin-hosted graph tasks through the dashboard workflow assignment payload. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5260be1c59..f0e6a2a93f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -173,6 +173,7 @@ Navigation: Behavior: - Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review` - Excludes `done` and `archived` +- On desktop/tablet, the header workflow dropdown mirrors Board/List selection behavior and filters graph nodes to tasks assigned to the selected workflow; **All workflows** restores the full active-task graph. - Uses Sugiyama-style layered auto-layout to place nodes by dependency depth - Renders directed bezier dependency edges (dependent → dependency) with arrowheads - Supports cursor-centered wheel zoom, pinch zoom, keyboard shortcuts (`Ctrl/Cmd+=`, `Ctrl/Cmd+-`, `Ctrl/Cmd+0`, `Ctrl/Cmd+Shift+F`, `Escape`), and fit/reset controls via the floating toolbar with live zoom percentage diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 6d9ae38755..719907ad55 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -107,6 +107,7 @@ import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { MainContent } from "./components/dashboard/MainContent"; import { DashboardBanners } from "./components/dashboard/DashboardBanners"; import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types"; +import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherSlot"; // ChatView's CSS is imported eagerly so the styles bundle into the main // CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS @@ -399,6 +400,7 @@ function AppInner() { }, [taskView, refreshTasks]); const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; + const [graphWorkflowSelection, setGraphWorkflowSelection] = useState(null); const [researchReadinessVersion, setResearchReadinessVersion] = useState(0); const mountTimeRef = useRef(performance.now()); @@ -1119,6 +1121,8 @@ function AppInner() { handleRemoveProject, nodes, graphPluginTaskView, + graphWorkflowSelection, + setGraphWorkflowSelection, isRemote, remoteData, tasks, diff --git a/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx b/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx new file mode 100644 index 0000000000..d4eee2430e --- /dev/null +++ b/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx @@ -0,0 +1,127 @@ +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 { + 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 REVIEW_WORKFLOW: BoardWorkflowDefinition = { + id: "wf-review", + name: "Review", + columns: [], +}; + +const TASKS = [ + { id: "FN-default", title: "Default task" }, + { id: "FN-unassigned", title: "Unassigned task" }, + { id: "FN-review", title: "Review task" }, + { id: "FN-unknown", title: "Unknown task" }, +]; + +function workflowPayload(overrides: Partial = {}): BoardWorkflowsPayload { + return { + flagEnabled: true, + defaultWorkflowId: DEFAULT_WORKFLOW.id, + workflows: [DEFAULT_WORKFLOW, REVIEW_WORKFLOW], + taskWorkflowIds: { + "FN-review": REVIEW_WORKFLOW.id, + "FN-unknown": "wf-missing", + }, + ...overrides, + }; +} + +function GraphPluginContextHarness({ projectId = "project-graph" }: { projectId?: string }) { + const [selection, setSelection] = useState(null); + const pluginTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, selection); + + return ( + <> +
+ +
    + {pluginTasks.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("Graph workflow header integration", () => { + it("ports the dropdown into the header and scopes graph plugin tasks by selected workflow", async () => { + render(); + + const headerSlot = screen.getByTestId("header-workflow-slot"); + const selector = await screen.findByTestId("workflow-switcher"); + expect(headerSlot.contains(selector)).toBe(true); + + const contextTasks = screen.getByTestId("graph-plugin-context-tasks"); + await waitFor(() => { + expect(within(contextTasks).getByTestId("graph-context-task-FN-default")).toBeInTheDocument(); + expect(within(contextTasks).getByTestId("graph-context-task-FN-unassigned")).toBeInTheDocument(); + expect(within(contextTasks).queryByTestId("graph-context-task-FN-review")).toBeNull(); + expect(within(contextTasks).queryByTestId("graph-context-task-FN-unknown")).toBeNull(); + }); + + fireEvent.click(selector); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review")); + + await waitFor(() => { + expect(within(contextTasks).getByTestId("graph-context-task-FN-review")).toBeInTheDocument(); + expect(within(contextTasks).queryByTestId("graph-context-task-FN-default")).toBeNull(); + expect(within(contextTasks).queryByTestId("graph-context-task-FN-unassigned")).toBeNull(); + expect(within(contextTasks).queryByTestId("graph-context-task-FN-unknown")).toBeNull(); + }); + }); + + it("keeps graph plugin tasks unfiltered when workflow mode is disabled or no project is selected", async () => { + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] })); + const { rerender } = render(); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-graph")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + for (const task of TASKS) { + expect(screen.getByTestId(`graph-context-task-${task.id}`)).toBeInTheDocument(); + } + + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload()); + rerender(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("")); + for (const task of TASKS) { + expect(screen.getByTestId(`graph-context-task-${task.id}`)).toBeInTheDocument(); + } + }); +}); diff --git a/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx new file mode 100644 index 0000000000..ebbe06305f --- /dev/null +++ b/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api"; +import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; +import { useViewportMode } from "../hooks/useViewportMode"; +import { WorkflowSwitcher } from "./WorkflowSwitcher"; +import type { WorkflowStatusCounts } from "./workflowStatusCounts"; + +export interface GraphWorkflowSelection { + boardWorkflows: BoardWorkflowsPayload; + selectedWorkflow: BoardWorkflowDefinition; +} + +interface GraphWorkflowSwitcherSlotProps { + projectId?: string; + onOpenWorkflowEditor?: () => void; + onCreateWorkflow?: () => void; + onWorkflowSelectionChange?: (selection: GraphWorkflowSelection | null) => void; +} + +const EMPTY_COUNTS: Map = new Map(); + +export function filterTasksByGraphWorkflowSelection( + tasks: T[], + projectId: string | undefined, + selection: GraphWorkflowSelection | null, +): T[] { + if (!projectId || !selection) return tasks; + return tasks.filter((task) => { + const assignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id] + ?? selection.boardWorkflows.defaultWorkflowId; + return assignedWorkflowId === selection.selectedWorkflow.id; + }); +} + +export function GraphWorkflowSwitcherSlot({ + projectId, + onOpenWorkflowEditor, + onCreateWorkflow, + onWorkflowSelectionChange, +}: GraphWorkflowSwitcherSlotProps) { + const { + boardWorkflows, + workflowMode, + workflowOptions, + selectedWorkflow, + setSelectedWorkflowId, + refreshBoardWorkflows, + } = useBoardWorkflows({ projectId }); + const viewportMode = useViewportMode(); + + const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { + if (typeof document === "undefined") return null; + return document.getElementById("header-workflow-slot"); + }); + + useEffect(() => { + if (typeof document === "undefined") return; + const resolve = () => { + const slot = document.getElementById("header-workflow-slot"); + setHeaderWorkflowSlot((previous) => (previous === slot ? previous : slot)); + return slot; + }; + if (resolve()) return; + /* + FNXC:GraphWorkflowSwitcher 2026-06-23-21:45: + Graph shares the Board/List header workflow affordance, but mobile and inactive left-sidebar layouts can omit `#header-workflow-slot`. Poll only briefly and re-resolve on viewport changes so Graph never spins forever or leaves an empty dropdown shell when the header slot is absent. + */ + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + if (resolve() || attempts >= 20) window.clearInterval(interval); + }, 250); + return () => window.clearInterval(interval); + }, [viewportMode]); + + const selection = useMemo(() => { + if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null; + return { boardWorkflows, selectedWorkflow }; + }, [boardWorkflows, selectedWorkflow, workflowMode]); + + useEffect(() => { + onWorkflowSelectionChange?.(selection); + }, [onWorkflowSelectionChange, selection]); + + useEffect(() => { + return () => onWorkflowSelectionChange?.(null); + }, [onWorkflowSelectionChange]); + + if (!workflowMode || !selectedWorkflow || workflowOptions.length < 2 || !headerWorkflowSlot) { + return null; + } + + return createPortal( +
+
+ +
+
, + headerWorkflowSlot, + ); +} diff --git a/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx new file mode 100644 index 0000000000..99c10e9f11 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/GraphWorkflowSwitcherSlot.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api"; +import { filterTasksByGraphWorkflowSelection, GraphWorkflowSwitcherSlot, type GraphWorkflowSelection } from "../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 CUSTOM_WORKFLOW: BoardWorkflowDefinition = { + id: "wf-review", + name: "Review", + columns: [], +}; + +function workflowPayload(overrides: Partial = {}): BoardWorkflowsPayload { + return { + flagEnabled: true, + defaultWorkflowId: DEFAULT_WORKFLOW.id, + workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + taskWorkflowIds: {}, + ...overrides, + }; +} + +function appendHeaderWorkflowSlot() { + const slot = document.createElement("div"); + slot.id = "header-workflow-slot"; + slot.className = "header-workflow-slot"; + document.body.appendChild(slot); + return slot; +} + +beforeEach(() => { + sessionStorage.clear(); + fetchBoardWorkflowsMock.mockReset(); + subscribeSseMock.mockClear(); + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload()); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); +}); + +afterEach(() => { + document.getElementById("header-workflow-slot")?.remove(); + vi.restoreAllMocks(); +}); + +describe("filterTasksByGraphWorkflowSelection", () => { + it("uses task workflow assignments with default fallback for graph scoping", () => { + const tasks = [ + { id: "FN-default" }, + { id: "FN-unassigned" }, + { id: "FN-review" }, + { id: "FN-unknown" }, + ]; + const selection: GraphWorkflowSelection = { + boardWorkflows: workflowPayload({ + taskWorkflowIds: { + "FN-review": CUSTOM_WORKFLOW.id, + "FN-unknown": "wf-missing", + }, + }), + selectedWorkflow: DEFAULT_WORKFLOW, + }; + + expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection).map((task) => task.id)).toEqual([ + "FN-default", + "FN-unassigned", + ]); + expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", { ...selection, selectedWorkflow: CUSTOM_WORKFLOW }).map((task) => task.id)).toEqual([ + "FN-review", + ]); + }); + + it("preserves unfiltered graph tasks without a project or workflow payload", () => { + const tasks = [{ id: "FN-a" }, { id: "FN-b" }]; + const selection: GraphWorkflowSelection = { + boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }), + selectedWorkflow: CUSTOM_WORKFLOW, + }; + + expect(filterTasksByGraphWorkflowSelection(tasks, undefined, selection)).toBe(tasks); + expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", null)).toBe(tasks); + }); +}); + +describe("GraphWorkflowSwitcherSlot", () => { + it("portals the shared workflow switcher into the header workflow slot", async () => { + const headerSlot = appendHeaderWorkflowSlot(); + const onWorkflowSelectionChange = vi.fn(); + + render(); + + const selector = await screen.findByTestId("workflow-switcher"); + expect(headerSlot.contains(selector)).toBe(true); + expect(headerSlot.querySelector(".board-workflow-toolbar")).not.toBeNull(); + await waitFor(() => { + expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith({ + boardWorkflows: workflowPayload(), + selectedWorkflow: DEFAULT_WORKFLOW, + }); + }); + }); + + it("refreshes the board-workflows payload when the dropdown opens", async () => { + appendHeaderWorkflowSlot(); + render(); + + const selector = await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1)); + fireEvent.click(selector); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2)); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + }); + + it("reports selection changes so App can scope graph tasks", async () => { + appendHeaderWorkflowSlot(); + const onWorkflowSelectionChange = vi.fn(); + render(); + + fireEvent.click(await screen.findByTestId("workflow-switcher")); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review")); + + await waitFor(() => { + const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null; + expect(lastSelection?.selectedWorkflow.id).toBe("wf-review"); + }); + }); + + it("renders no dropdown shell when the header slot is absent", async () => { + render(); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled()); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(document.querySelector(".board-workflow-toolbar")).toBeNull(); + }); + + it("renders no dropdown shell when workflow mode is disabled, empty, or not switchable", async () => { + const headerSlot = appendHeaderWorkflowSlot(); + const { rerender } = render(); + + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] })); + rerender(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-disabled-next")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(headerSlot.childElementCount).toBe(0); + + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] })); + rerender(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(headerSlot.childElementCount).toBe(0); + + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [DEFAULT_WORKFLOW] })); + rerender(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-single")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(headerSlot.childElementCount).toBe(0); + }); +}); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index cea7bbb71b..c3aab3f1df 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -16,6 +16,7 @@ import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage"; import { CapacityRiskBanner } from "../CapacityRiskBanner"; import { PlanningModeModal } from "../PlanningModeModal"; import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot"; +import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot"; import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost"; import { isPluginViewId } from "../../plugins/pluginViewRegistry"; import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical"; @@ -57,6 +58,8 @@ export function MainContent({ handleRemoveProject, nodes, graphPluginTaskView, + graphWorkflowSelection, + setGraphWorkflowSelection, isRemote, remoteData, tasks, @@ -241,13 +244,29 @@ export function MainContent({ // Project view if (resolvedPluginTaskView) { const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; + const isDependencyGraphView = resolvedPluginTaskView === "plugin:fusion-plugin-dependency-graph:graph"; + /* + FNXC:GraphWorkflowSwitcher 2026-06-23-22:04: + The dependency Graph is plugin-hosted, so App scopes the normal `tasks` array before it enters PluginDashboardViewHost instead of teaching the graph plugin about workflow metadata. This preserves the plugin context contract while matching Board/List workflow assignment fallback: `taskWorkflowIds[task.id] ?? defaultWorkflowId` must equal the selected header workflow. + */ + const pluginContextTasks = isDependencyGraphView + ? filterTasksByGraphWorkflowSelection(pluginTasks, currentProject?.id, graphWorkflowSelection) + : pluginTasks; return ( + {isDependencyGraphView ? ( + + ) : null} openDetailTask(task, initialTab), @@ -263,7 +282,7 @@ export function MainContent({ prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMerge} nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string" - ? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) + ? isNearDuplicateCanonicalInactive(pluginContextTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) : undefined} /> ), diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 6e5769883f..ab32cecc37 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -37,6 +37,7 @@ import type { UseRemoteNodeDataResult } from "../../hooks/useRemoteNodeData"; import type { SectionId } from "../SettingsModal"; import type { CliActionId } from "../SessionNotificationBanner"; import type { ApprovalBannerCandidate } from "../../utils/appLifecycle"; +import type { GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot"; // The lazy view components are value exports; importing them as values lets us // spell their types via `typeof` so MainContent's JSX gets full prop checking. import { SettingsView } from "../SettingsModal"; @@ -91,6 +92,8 @@ export interface MainContentProps { handleRemoveProject: (project: ProjectInfo) => Promise; nodes: NodeInfo[]; graphPluginTaskView: PluginTaskView | null; + graphWorkflowSelection: GraphWorkflowSelection | null; + setGraphWorkflowSelection: Dispatch>; isRemote: boolean; remoteData: UseRemoteNodeDataResult; tasks: Task[];