Files
fusion/packages/dashboard/app/components/GraphWorkflowSwitcherSlot.tsx
gsxdsm b3b01bc25d FN-7357: add all workflows to top-level selectors
Extend the dashboard aggregate workflow view across top-level workflow selectors without leaking its sentinel into task creation.

- Add All workflows as an aggregate option for List, Header, Planning/Missions, and Graph selectors.\n- Preserve real workflow handoffs for quick create, planning, missions, and workflow editor entry points.\n- Include aggregate workflow counts and cross-surface regression coverage, plus docs and a changeset.\n\nFiles changed:\n .changeset/fn-7357-all-workflows-selectors.md      |  7 +++\n docs/dashboard-guide.md                            |  9 ++--\n .../workflow-selection-cross-surface.test.tsx      | 20 ++++---\n .../app/components/GraphWorkflowSwitcherSlot.tsx   | 12 +++--\n .../app/components/HeaderWorkflowSwitcherSlot.tsx  | 10 ++--\n packages/dashboard/app/components/ListView.tsx     | 63 +++++++++++++++++-----\n .../components/PlanningWorkflowSwitcherSlot.tsx    |  5 +-\n .../__tests__/GraphWorkflowSwitcherSlot.test.tsx   | 34 ++++++++++++\n .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx  | 27 ++++++++++\n .../app/components/__tests__/ListView.test.tsx     | 41 +++++++++++++-\n .../app/components/dashboard/MainContent.tsx       | 17 ++++--\n .../app/components/workflowStatusCounts.ts         |  5 ++\n packages/dashboard/app/hooks/useBoardWorkflows.ts  | 20 +++++--\n .../dashboard/app/utils/boardWorkflowSelection.ts  |  5 +-\n 14 files changed, 233 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-7357

Fusion-Task-Lineage: b8658cad-7b12-4a06-b627-e2bde7de6951

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-01 07:33:56 -07:00

126 lines
5.0 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
import { useViewportMode } from "../hooks/useViewportMode";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
export interface GraphWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition;
isAllWorkflowsSelected: boolean;
}
interface GraphWorkflowSwitcherSlotProps {
projectId?: string;
/*
FNXC:WorkflowEditorFloating 2026-06-24-00:00:
Graph shares the Board/List workflow dropdown contract, so row edit must forward the workflow id into the floating editor. Keeping the parameter prevents Graph edits from falling back to the default workflow.
*/
onOpenWorkflowEditor?: (workflowId?: string) => 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 || selection.isAllWorkflowsSelected) return tasks;
const workflowIds = new Set(selection.boardWorkflows.workflows.map((workflow) => workflow.id));
return tasks.filter((task) => {
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;
});
}
export function GraphWorkflowSwitcherSlot({
projectId,
onOpenWorkflowEditor,
onCreateWorkflow,
onWorkflowSelectionChange,
}: GraphWorkflowSwitcherSlotProps) {
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
isAllWorkflowsSelected,
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, isAllWorkflowsSelected };
}, [boardWorkflows, isAllWorkflowsSelected, 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={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
</div>,
headerWorkflowSlot,
);
}