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:
|
Behavior:
|
||||||
- Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review`
|
- Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review`
|
||||||
- Excludes `done` and `archived`
|
- 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
|
- Uses Sugiyama-style layered auto-layout to place nodes by dependency depth
|
||||||
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
|
- 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
|
- 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 { MainContent } from "./components/dashboard/MainContent";
|
||||||
import { DashboardBanners } from "./components/dashboard/DashboardBanners";
|
import { DashboardBanners } from "./components/dashboard/DashboardBanners";
|
||||||
import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types";
|
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
|
// 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
|
// CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS
|
||||||
@@ -399,6 +400,7 @@ function AppInner() {
|
|||||||
}, [taskView, refreshTasks]);
|
}, [taskView, refreshTasks]);
|
||||||
|
|
||||||
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
|
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
|
||||||
|
const [graphWorkflowSelection, setGraphWorkflowSelection] = useState<GraphWorkflowSelection | null>(null);
|
||||||
|
|
||||||
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
|
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
|
||||||
const mountTimeRef = useRef(performance.now());
|
const mountTimeRef = useRef(performance.now());
|
||||||
@@ -1119,6 +1121,8 @@ function AppInner() {
|
|||||||
handleRemoveProject,
|
handleRemoveProject,
|
||||||
nodes,
|
nodes,
|
||||||
graphPluginTaskView,
|
graphPluginTaskView,
|
||||||
|
graphWorkflowSelection,
|
||||||
|
setGraphWorkflowSelection,
|
||||||
isRemote,
|
isRemote,
|
||||||
remoteData,
|
remoteData,
|
||||||
tasks,
|
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 { CapacityRiskBanner } from "../CapacityRiskBanner";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
|
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
|
||||||
|
import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
|
||||||
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
|
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
|
||||||
import { isPluginViewId } from "../../plugins/pluginViewRegistry";
|
import { isPluginViewId } from "../../plugins/pluginViewRegistry";
|
||||||
import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical";
|
import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical";
|
||||||
@@ -57,6 +58,8 @@ export function MainContent({
|
|||||||
handleRemoveProject,
|
handleRemoveProject,
|
||||||
nodes,
|
nodes,
|
||||||
graphPluginTaskView,
|
graphPluginTaskView,
|
||||||
|
graphWorkflowSelection,
|
||||||
|
setGraphWorkflowSelection,
|
||||||
isRemote,
|
isRemote,
|
||||||
remoteData,
|
remoteData,
|
||||||
tasks,
|
tasks,
|
||||||
@@ -241,13 +244,29 @@ export function MainContent({
|
|||||||
// Project view
|
// Project view
|
||||||
if (resolvedPluginTaskView) {
|
if (resolvedPluginTaskView) {
|
||||||
const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
|
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 (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
|
{isDependencyGraphView ? (
|
||||||
|
<GraphWorkflowSwitcherSlot
|
||||||
|
projectId={currentProject?.id}
|
||||||
|
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||||
|
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||||
|
onWorkflowSelectionChange={setGraphWorkflowSelection}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<PluginDashboardViewHost
|
<PluginDashboardViewHost
|
||||||
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
|
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
|
||||||
context={{
|
context={{
|
||||||
projectId: currentProject?.id,
|
projectId: currentProject?.id,
|
||||||
tasks: pluginTasks,
|
tasks: pluginContextTasks,
|
||||||
workflowSteps,
|
workflowSteps,
|
||||||
subscribePluginEvents,
|
subscribePluginEvents,
|
||||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
|
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
|
||||||
@@ -263,7 +282,7 @@ export function MainContent({
|
|||||||
prAuthAvailable={prAuthAvailable}
|
prAuthAvailable={prAuthAvailable}
|
||||||
autoMergeEnabled={autoMerge}
|
autoMergeEnabled={autoMerge}
|
||||||
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
|
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}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type { UseRemoteNodeDataResult } from "../../hooks/useRemoteNodeData";
|
|||||||
import type { SectionId } from "../SettingsModal";
|
import type { SectionId } from "../SettingsModal";
|
||||||
import type { CliActionId } from "../SessionNotificationBanner";
|
import type { CliActionId } from "../SessionNotificationBanner";
|
||||||
import type { ApprovalBannerCandidate } from "../../utils/appLifecycle";
|
import type { ApprovalBannerCandidate } from "../../utils/appLifecycle";
|
||||||
|
import type { GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
|
||||||
// The lazy view components are value exports; importing them as values lets us
|
// 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.
|
// spell their types via `typeof` so MainContent's JSX gets full prop checking.
|
||||||
import { SettingsView } from "../SettingsModal";
|
import { SettingsView } from "../SettingsModal";
|
||||||
@@ -91,6 +92,8 @@ export interface MainContentProps {
|
|||||||
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
|
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
graphPluginTaskView: PluginTaskView | null;
|
graphPluginTaskView: PluginTaskView | null;
|
||||||
|
graphWorkflowSelection: GraphWorkflowSelection | null;
|
||||||
|
setGraphWorkflowSelection: Dispatch<SetStateAction<GraphWorkflowSelection | null>>;
|
||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
remoteData: UseRemoteNodeDataResult;
|
remoteData: UseRemoteNodeDataResult;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
|||||||
Reference in New Issue
Block a user