Files
fusion/packages/dashboard/app/components/__tests__/HeaderWorkflowSwitcherSlot.test.tsx
gsxdsm 9464f31caf FN-7406: fix All workflows dropdown counts
Ensure All workflows dropdown counters report the shared aggregate without double-counting tasks.

- Reuse computeWorkflowStatusCounts directly in Board instead of re-summing a map that already includes the All workflows sentinel.
- Document the aggregate count invariant and add a published patch changeset.
- Extend Board, List, header, graph, and workflow count tests to cover exact All workflows totals.

Files changed:
 .changeset/fn-7406-workflow-dropdown-counts.md     |  7 +++
 docs/dashboard-guide.md                            |  5 +-
 packages/dashboard/app/components/Board.tsx        | 17 +++---
 .../app/components/__tests__/Board.test.tsx        | 56 ++++++++++++++++++-
 .../__tests__/GraphWorkflowSwitcherSlot.test.tsx   |  8 ++-
 .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx  |  8 ++-
 .../app/components/__tests__/ListView.test.tsx     | 62 ++++++++++++++++++++++
 .../__tests__/workflowStatusCounts.test.ts         | 48 +++++++++++++++--
 8 files changed, 188 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-7406

Fusion-Task-Lineage: bb344c03-c349-47ea-9e8c-c445975f8e02

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-01 23:15:12 -07:00

206 lines
9.0 KiB
TypeScript

/*
FNXC:MissionWorkflows 2026-06-25-00:00:
The shared header workflow slot is the canonical desktop workflow selector for Planning and Missions, and it must render no leftover toolbar shell when workflow mode cannot be switched.
*/
import type { ReactNode } 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 { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
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 MISSION_WORKFLOW: BoardWorkflowDefinition = {
id: "wf-missions",
name: "Missions",
columns: [],
};
function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW.id,
workflows: [DEFAULT_WORKFLOW, MISSION_WORKFLOW],
taskWorkflowIds: {},
...overrides,
};
}
function renderWithHeader(children: ReactNode) {
return render(
<>
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
{children}
</>,
);
}
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchBoardWorkflowsMock.mockReset();
subscribeSseMock.mockClear();
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("HeaderWorkflowSwitcherSlot", () => {
it("ports the workflow switcher into the desktop header slot and reports selection changes", async () => {
const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
renderWithHeader(
<HeaderWorkflowSwitcherSlot projectId="project-missions" onWorkflowSelectionChange={onWorkflowSelectionChange} />,
);
const headerSlot = screen.getByTestId("header-workflow-slot");
const selector = await screen.findByTestId("workflow-switcher");
expect(headerSlot.contains(selector)).toBe(true);
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
}));
});
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-missions"));
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
selectedWorkflow: expect.objectContaining({ id: MISSION_WORKFLOW.id }),
}));
});
});
it("keeps the Planning wrapper rendering the shared header switcher", async () => {
renderWithHeader(<PlanningWorkflowSwitcherSlot projectId="project-planning" />);
const headerSlot = screen.getByTestId("header-workflow-slot");
const selector = await screen.findByTestId("workflow-switcher");
expect(headerSlot.contains(selector)).toBe(true);
});
it("hydrates a remounted desktop header selector from durable project storage", async () => {
const { unmount } = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-persist" />);
fireEvent.click(await screen.findByTestId("workflow-switcher"));
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-missions"));
await waitFor(() => expect(screen.getByTestId("workflow-switcher")).toHaveTextContent(MISSION_WORKFLOW.name));
unmount();
fetchBoardWorkflowsMock.mockImplementation(() => new Promise<BoardWorkflowsPayload>(() => {}));
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-persist" />);
expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(MISSION_WORKFLOW.name);
});
it("repairs a stale stored header workflow id to the default workflow", async () => {
localStorage.setItem("kb:project-header-stale:kb-dashboard-board-workflow-selection", "wf-deleted");
const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
renderWithHeader(
<HeaderWorkflowSwitcherSlot projectId="project-header-stale" onWorkflowSelectionChange={onWorkflowSelectionChange} />,
);
expect(await screen.findByTestId("workflow-switcher")).toHaveTextContent(DEFAULT_WORKFLOW.name);
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
}));
});
expect(localStorage.getItem("kb:project-header-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id);
});
it("exposes all workflows as an aggregate non-editable header selection", async () => {
const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
const onOpenWorkflowEditor = vi.fn();
renderWithHeader(
<HeaderWorkflowSwitcherSlot
projectId="project-header-all"
onWorkflowSelectionChange={onWorkflowSelectionChange}
onOpenWorkflowEditor={onOpenWorkflowEditor}
/>,
);
fireEvent.click(await screen.findByTestId("workflow-switcher"));
const aggregateOption = screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`);
expect(aggregateOption).toHaveTextContent("All workflows");
expect(within(aggregateOption).getByTitle("Todo: 0")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("In Progress: 0")).toBeInTheDocument();
expect(within(aggregateOption).getByTitle("Done: 0")).toBeInTheDocument();
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
isAllWorkflowsSelected: true,
selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
}));
});
expect(onOpenWorkflowEditor).not.toHaveBeenCalledWith(ALL_WORKFLOWS_BOARD_VIEW_ID);
expect(localStorage.getItem("kb:project-header-all:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
});
it("forwards dropdown edit workflow ids from the shared header slot", async () => {
const onOpenWorkflowEditor = vi.fn();
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-edit" onOpenWorkflowEditor={onOpenWorkflowEditor} />);
fireEvent.click(await screen.findByTestId("workflow-switcher"));
fireEvent.click(screen.getByTestId("workflow-switcher-edit-wf-missions"));
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-missions");
});
it("renders no toolbar shell when workflow mode is off, empty, or only one workflow exists", async () => {
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ flagEnabled: false, workflows: [] }));
const { unmount } = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-off" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-off"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
unmount();
sessionStorage.clear();
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [] }));
const empty = renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-empty" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
empty.unmount();
sessionStorage.clear();
fetchBoardWorkflowsMock.mockResolvedValueOnce(workflowPayload({ workflows: [DEFAULT_WORKFLOW] }));
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-one" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-one"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
});
it("renders no toolbar shell when the header slot is absent", async () => {
render(<HeaderWorkflowSwitcherSlot projectId="project-mobile" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-mobile"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
});
});