FN-6941: add graph workflow filtering
Adds a Graph view header workflow dropdown that filters dependency graph tasks by the selected workflow. - Portal the workflow switcher into the Graph view header and clear selection when unmounted. - Scope plugin graph task context using workflow assignments and default workflow fallback. - Cover the header integration, task filtering, portal lifecycle, and disabled workflow mode. - Document Graph workflow filtering and add the published package changeset. Files changed: .changeset/fn-6941-graph-workflow-dropdown.md | 7 + docs/dashboard-guide.md | 1 + packages/dashboard/app/App.tsx | 4 + .../app/__tests__/graph-workflow-header.test.tsx | 127 +++++++++++++++ .../app/components/GraphWorkflowSwitcherSlot.tsx | 110 +++++++++++++ .../__tests__/GraphWorkflowSwitcherSlot.test.tsx | 173 +++++++++++++++++++++ .../app/components/dashboard/MainContent.tsx | 23 ++- .../dashboard/app/components/dashboard/types.ts | 3 + 8 files changed, 446 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6941 Fusion-Task-Lineage: a77bc99e-00fe-42fe-a976-d88ffd803388
This commit is contained in:
7
.changeset/fn-6941-graph-workflow-dropdown.md
Normal file
7
.changeset/fn-6941-graph-workflow-dropdown.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<GraphWorkflowSelection | null>(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,
|
||||
|
||||
127
packages/dashboard/app/__tests__/graph-workflow-header.test.tsx
Normal file
127
packages/dashboard/app/__tests__/graph-workflow-header.test.tsx
Normal file
@@ -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> = {}): 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<GraphWorkflowSelection | null>(null);
|
||||
const pluginTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, selection);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
|
||||
<GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setSelection} />
|
||||
<ul data-testid="graph-plugin-context-tasks">
|
||||
{pluginTasks.map((task) => (
|
||||
<li key={task.id} data-testid={`graph-context-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("Graph workflow header integration", () => {
|
||||
it("ports the dropdown into the header and scopes graph plugin tasks by selected workflow", async () => {
|
||||
render(<GraphPluginContextHarness />);
|
||||
|
||||
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(<GraphPluginContextHarness />);
|
||||
|
||||
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(<GraphPluginContextHarness projectId="" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith(""));
|
||||
for (const task of TASKS) {
|
||||
expect(screen.getByTestId(`graph-context-task-${task.id}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
110
packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx
Normal file
110
packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx
Normal file
@@ -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<string, WorkflowStatusCounts> = new Map();
|
||||
|
||||
export function filterTasksByGraphWorkflowSelection<T extends { id: string }>(
|
||||
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<HTMLElement | null>(() => {
|
||||
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<GraphWorkflowSelection | null>(() => {
|
||||
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(
|
||||
<div className="board-workflow-toolbar">
|
||||
<div className="board-workflow-selector">
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={EMPTY_COUNTS}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
onEditWorkflow={onOpenWorkflowEditor}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
headerWorkflowSlot,
|
||||
);
|
||||
}
|
||||
@@ -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> = {}): 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(<GraphWorkflowSwitcherSlot projectId="project-graph" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
|
||||
|
||||
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(<GraphWorkflowSwitcherSlot projectId="project-refresh" />);
|
||||
|
||||
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(<GraphWorkflowSwitcherSlot projectId="project-select" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
|
||||
|
||||
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(<GraphWorkflowSwitcherSlot projectId="project-no-slot" />);
|
||||
|
||||
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(<GraphWorkflowSwitcherSlot projectId="project-disabled" />);
|
||||
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));
|
||||
rerender(<GraphWorkflowSwitcherSlot projectId="project-disabled-next" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-disabled-next"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(headerSlot.childElementCount).toBe(0);
|
||||
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] }));
|
||||
rerender(<GraphWorkflowSwitcherSlot projectId="project-empty" />);
|
||||
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(<GraphWorkflowSwitcherSlot projectId="project-single" />);
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-single"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(headerSlot.childElementCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<PageErrorBoundary>
|
||||
{isDependencyGraphView ? (
|
||||
<GraphWorkflowSwitcherSlot
|
||||
projectId={currentProject?.id}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
onWorkflowSelectionChange={setGraphWorkflowSelection}
|
||||
/>
|
||||
) : null}
|
||||
<PluginDashboardViewHost
|
||||
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
|
||||
context={{
|
||||
projectId: currentProject?.id,
|
||||
tasks: pluginTasks,
|
||||
tasks: pluginContextTasks,
|
||||
workflowSteps,
|
||||
subscribePluginEvents,
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => 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}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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<void>;
|
||||
nodes: NodeInfo[];
|
||||
graphPluginTaskView: PluginTaskView | null;
|
||||
graphWorkflowSelection: GraphWorkflowSelection | null;
|
||||
setGraphWorkflowSelection: Dispatch<SetStateAction<GraphWorkflowSelection | null>>;
|
||||
isRemote: boolean;
|
||||
remoteData: UseRemoteNodeDataResult;
|
||||
tasks: Task[];
|
||||
|
||||
Reference in New Issue
Block a user