Add regression coverage and stale-workflow fallback for workflow-aware dashboard selection. - Cover cached board-workflow hydration across remounted Header and Graph switchers. - Verify Header and Graph selections remain isolated while Graph task filtering follows the Graph selection. - Treat deleted workflow assignments as default-workflow tasks so they remain visible. - Add hook coverage for cache hydration, project changes, selection fallbacks, disabled workflow mode, and consumer isolation. - Add a patch changeset for the stale Graph workflow filtering fix. Files changed: .changeset/fn-7057-graph-workflow-filter.md | 7 + .../workflow-selection-cross-surface.test.tsx | 184 +++++++++++++++++++++ .../app/components/GraphWorkflowSwitcherSlot.tsx | 11 +- .../__tests__/GraphWorkflowSwitcherSlot.test.tsx | 1 + .../app/hooks/__tests__/useBoardWorkflows.test.ts | 133 ++++++++++++++- 5 files changed, 330 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7057 Fusion-Task-Lineage: 52e6682a-576e-4ed6-92d5-03e0e9b5896c
122 lines
4.6 KiB
TypeScript
122 lines
4.6 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 { useViewportMode } from "../hooks/useViewportMode";
|
|
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
|
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
|
|
|
export interface GraphWorkflowSelection {
|
|
boardWorkflows: BoardWorkflowsPayload;
|
|
selectedWorkflow: BoardWorkflowDefinition;
|
|
}
|
|
|
|
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) 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,
|
|
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,
|
|
);
|
|
}
|