From 7eceec00cc4fc717978e9ffeed3e6ef5f35ff093 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 02:08:36 -0700 Subject: [PATCH] FN-6763: add themed workflow status switcher Add a reusable dashboard workflow picker that shows inline task status counts across board and list views. - Replace native workflow selects with a themed accessible dropdown component. - Share workflow status count calculation between board and list views. - Add styling, i18n labels, tests, and dashboard docs for the switcher behavior. Files changed: docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/Board.tsx | 28 +-- packages/dashboard/app/components/Lane.css | 2 + .../dashboard/app/components/LeftSidebarNav.tsx | 2 +- packages/dashboard/app/components/ListView.css | 17 -- packages/dashboard/app/components/ListView.tsx | 31 ++- .../dashboard/app/components/WorkflowSwitcher.css | 186 ++++++++++++++ .../dashboard/app/components/WorkflowSwitcher.tsx | 270 +++++++++++++++++++++ .../app/components/__tests__/Board.test.tsx | 60 +++-- .../app/components/__tests__/ListView.test.tsx | 79 +++++- .../components/__tests__/WorkflowSwitcher.test.tsx | 92 +++++++ .../__tests__/workflowStatusCounts.test.ts | 122 ++++++++++ .../app/components/workflowStatusCounts.ts | 54 +++++ packages/i18n/locales/en/app.json | 9 + packages/i18n/locales/es/app.json | 9 + packages/i18n/locales/fr/app.json | 9 + packages/i18n/locales/ko/app.json | 9 + packages/i18n/locales/zh-CN/app.json | 9 + packages/i18n/locales/zh-TW/app.json | 9 + 19 files changed, 930 insertions(+), 70 deletions(-) Fusion-Task-Id: FN-6763 Fusion-Task-Lineage: 3503ea85-841a-4cd3-9960-1b22ef2cd4ea --- docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/Board.tsx | 28 +- packages/dashboard/app/components/Lane.css | 2 + .../app/components/LeftSidebarNav.tsx | 2 +- .../dashboard/app/components/ListView.css | 17 -- .../dashboard/app/components/ListView.tsx | 31 +- .../app/components/WorkflowSwitcher.css | 186 ++++++++++++ .../app/components/WorkflowSwitcher.tsx | 270 ++++++++++++++++++ .../app/components/__tests__/Board.test.tsx | 60 +++- .../components/__tests__/ListView.test.tsx | 79 ++++- .../__tests__/WorkflowSwitcher.test.tsx | 92 ++++++ .../__tests__/workflowStatusCounts.test.ts | 122 ++++++++ .../app/components/workflowStatusCounts.ts | 54 ++++ packages/i18n/locales/en/app.json | 9 + packages/i18n/locales/es/app.json | 9 + packages/i18n/locales/fr/app.json | 9 + packages/i18n/locales/ko/app.json | 9 + packages/i18n/locales/zh-CN/app.json | 9 + packages/i18n/locales/zh-TW/app.json | 9 + 19 files changed, 930 insertions(+), 70 deletions(-) create mode 100644 packages/dashboard/app/components/WorkflowSwitcher.css create mode 100644 packages/dashboard/app/components/WorkflowSwitcher.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts create mode 100644 packages/dashboard/app/components/workflowStatusCounts.ts diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e350c0d282..caf77ccd7e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -77,6 +77,7 @@ Features: - Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) - On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. +- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns. ![Board view](./screenshots/dashboard-overview.png) @@ -126,7 +127,7 @@ The workflow editor opens as a full-screen modal editor for inspecting built-ins Navigation: - Open a task or board surface that shows the workflow selector, then choose **Manage…**. -- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected. +- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected. The board/list workflow dropdown also previews each workflow's Todo / In Progress / Done task counts inline before switching. - Use the global **Workflow** / **Workflows** entry point from desktop header, compact header overflow, or mobile **More** navigation to browse definitions. - From Settings moved-setting stubs, choose **Open workflow settings** to jump to the default workflow's settings values. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 7430d23246..0fb9e53099 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -12,6 +12,8 @@ import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { subscribeSse } from "../sse-bus"; import { getBoardCanDropTaskRejection } from "./boardCanDropTask"; +import { WorkflowSwitcher } from "./WorkflowSwitcher"; +import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; interface BoardProps { tasks: Task[]; @@ -409,6 +411,11 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask ?? null; }, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]); + const workflowStatusCounts = useMemo( + () => computeWorkflowStatusCounts(tasks, boardWorkflows), + [boardWorkflows, tasks], + ); + useEffect(() => { if (!workflowMode) { setSelectedWorkflowId(null); @@ -506,21 +513,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask {(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
{workflowOptions.length > 1 && ( - + onChange={setSelectedWorkflowId} + counts={workflowStatusCounts} + /> +
)} {onOpenWorkflowEditor && ( + ); + })} + + , + portalRoot, + ) + : null; + + return ( +
+ {label} + + {dropdown} +
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index c18e70f2a2..7fdc22679b 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -138,6 +138,17 @@ function renderBoard(props = {}) { return render(); } +async function openWorkflowSwitcher() { + const trigger = await screen.findByTestId("workflow-switcher"); + fireEvent.click(trigger); + return trigger; +} + +async function selectWorkflow(workflowId: string) { + await openWorkflowSwitcher(); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`)); +} + describe("Board", () => { it("renders a
element with class 'board'", () => { renderBoard(); @@ -933,9 +944,25 @@ describe("Board", () => { const toolbar = document.querySelector(".board-workflow-toolbar"); expect(toolbar).not.toBeNull(); return Array.from(toolbar?.querySelectorAll("button") ?? []) + .filter((button) => button.getAttribute("data-testid") !== "workflow-switcher") .map((button) => button.getAttribute("aria-label")); } + async function openWorkflowSwitcher() { + const trigger = await screen.findByTestId("workflow-switcher"); + fireEvent.click(trigger); + return trigger; + } + + function workflowSwitcherOptionIds() { + return screen.getAllByRole("option").map((option) => option.getAttribute("data-testid")?.replace("workflow-switcher-option-", "")); + } + + async function selectWorkflow(workflowId: string) { + await openWorkflowSwitcher(); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`)); + } + it("flag OFF renders the legacy single-lane board byte-identically", async () => { fetchBoardWorkflowsMock.mockResolvedValue({ flagEnabled: false, @@ -962,7 +989,7 @@ describe("Board", () => { await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined()); expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]); expect(JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-2"]); - expect(screen.queryByLabelText("Select workflow")).toBeNull(); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); }); it("puts create controls on the workflow intake column instead of the first visible column", async () => { @@ -1014,7 +1041,7 @@ describe("Board", () => { }); await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined()); - expect(screen.queryByLabelText("Select workflow")).toBeNull(); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); expect(workflowToolbarActionNames()).toEqual(["Edit workflows", "New workflow"]); fireEvent.click(screen.getByRole("button", { name: "New workflow" })); @@ -1058,7 +1085,7 @@ describe("Board", () => { onOpenWorkflowEditor, }); - expect(await screen.findByLabelText("Select workflow")).toBeDefined(); + expect(await screen.findByTestId("workflow-switcher")).toBeDefined(); expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined(); expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined(); const toolbar = document.querySelector(".board-workflow-toolbar"); @@ -1085,8 +1112,12 @@ describe("Board", () => { onCreateWorkflow, onOpenWorkflowEditor, }); - const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement; - expect(selector.value).toBe("builtin:coding"); + const selector = await screen.findByTestId("workflow-switcher"); + expect(selector).toHaveTextContent("Coding"); + expect(selector).toHaveTextContent("1"); + await openWorkflowSwitcher(); + expect(screen.getByTestId("workflow-switcher-option-wf-custom")).toHaveTextContent("2"); + fireEvent.keyDown(selector, { key: "Escape" }); expect(workflowToolbarActionNames()).toEqual(["Edit workflows", "New workflow"]); fireEvent.click(screen.getByRole("button", { name: "New workflow" })); fireEvent.click(screen.getByRole("button", { name: "Edit workflows" })); @@ -1095,7 +1126,7 @@ describe("Board", () => { expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]); expect(screen.queryByTestId("column-intake")).toBeNull(); - fireEvent.change(selector, { target: { value: "wf-custom" } }); + await selectWorkflow("wf-custom"); await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined()); expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort()).toEqual(["FN-2", "FN-3"]); expect(screen.queryByTestId("column-todo")).toBeNull(); @@ -1110,10 +1141,9 @@ describe("Board", () => { [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], ); renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] }); - const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement; - const options = [...selector.options].map((option) => option.value); - expect(options).toEqual(["builtin:coding", "wf-custom"]); - expect(selector.value).toBe("builtin:coding"); + const selector = await openWorkflowSwitcher(); + expect(workflowSwitcherOptionIds()).toEqual(["builtin:coding", "wf-custom"]); + expect(selector).toHaveTextContent("Coding"); expect(screen.queryByTestId("column-intake")).toBeNull(); }); @@ -1123,8 +1153,8 @@ describe("Board", () => { [CUSTOM_WORKFLOW, DEFAULT_WORKFLOW], ); renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] }); - const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement; - expect([...selector.options].map((option) => option.value)).toEqual(["builtin:coding", "wf-custom"]); + await openWorkflowSwitcher(); + expect(workflowSwitcherOptionIds()).toEqual(["builtin:coding", "wf-custom"]); }); it("renders archived cards in the selected workflow archived column", async () => { @@ -1241,9 +1271,9 @@ describe("Board", () => { updatedAt: "2024-01-01T00:00:00.000Z", } as Task] }); - const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement; + const selector = await screen.findByTestId("workflow-switcher"); await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"])); - expect(selector.value).toBe("builtin:coding"); + expect(selector).toHaveTextContent("Coding"); await act(async () => { sseHandlers["workflow:updated"]?.(); @@ -1252,7 +1282,7 @@ describe("Board", () => { await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2)); await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual([])); - fireEvent.change(selector, { target: { value: "wf-preserved" } }); + await selectWorkflow("wf-preserved"); await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"])); }); }); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 9258c32aac..8432afa052 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -251,6 +251,17 @@ const keyDownAndFlush = async (element: Element, init: Parameters { clickInAct(screen.getByRole("button", { name: "Bulk Edit" })); }; @@ -686,9 +697,9 @@ describe("ListView", () => { tasks: [createMockTask({ id: "FN-001", column: "todo", title: "Preserved workflow task" })], }); - const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement; + const selector = await screen.findByTestId("workflow-switcher"); await waitFor(() => expect(screen.getByText("Preserved workflow task")).toBeInTheDocument()); - expect(selector.value).toBe("builtin:coding"); + expect(selector).toHaveTextContent("Coding"); await act(async () => { listViewSseHandlers["workflow:updated"]?.(); @@ -697,10 +708,67 @@ describe("ListView", () => { await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.queryByText("Preserved workflow task")).not.toBeInTheDocument()); - fireEvent.change(selector, { target: { value: "wf-preserved" } }); + await selectWorkflow("wf-preserved"); await waitFor(() => expect(screen.getByText("Preserved workflow task")).toBeInTheDocument()); }); + it("shows inline workflow counts in desktop and mobile switchers", async () => { + const workflowPayload = { + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "triage", name: "Triage", flags: { intake: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }, + { + id: "wf-custom", + name: "Custom", + columns: [ + { id: "backlog", name: "Backlog", flags: { intake: true } }, + { id: "complete", name: "Complete", flags: { complete: true } }, + ], + }, + ], + taskWorkflowIds: { "FN-001": "builtin:coding", "FN-002": "wf-custom" }, + }; + vi.mocked(fetchBoardWorkflows).mockResolvedValue(workflowPayload); + const desktopSpy = mockDesktopViewport(); + const desktop = renderListView({ + tasks: [ + createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }), + createMockTask({ id: "FN-002", column: "complete", title: "Custom done task" }), + ], + }); + + const desktopTrigger = await screen.findByTestId("workflow-switcher"); + expect(desktopTrigger).toHaveTextContent("Coding"); + expect(desktopTrigger).toHaveTextContent("1"); + await openWorkflowSwitcher(); + expect(screen.getByTestId("workflow-switcher-option-wf-custom")).toHaveTextContent("1"); + fireEvent.keyDown(desktopTrigger, { key: "Escape" }); + desktop.unmount(); + desktopSpy.mockRestore(); + + vi.mocked(fetchBoardWorkflows).mockResolvedValue(workflowPayload); + const mobileSpy = mockMobileViewport(); + renderListView({ + tasks: [ + createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }), + createMockTask({ id: "FN-002", column: "complete", title: "Custom done task" }), + ], + }); + + const mobileTrigger = await screen.findByTestId("workflow-switcher"); + expect(mobileTrigger).toHaveTextContent("Coding"); + expect(mobileTrigger).toHaveTextContent("1"); + mobileSpy.mockRestore(); + }); + it("shows a new-workflow action next to the workflow selector", async () => { const onCreateWorkflow = vi.fn(); vi.mocked(fetchBoardWorkflows).mockResolvedValue({ @@ -732,7 +800,7 @@ describe("ListView", () => { onCreateWorkflow, }); - await screen.findByLabelText("Select workflow"); + await screen.findByTestId("workflow-switcher"); const createButtons = screen.getAllByRole("button", { name: "New workflow" }); expect(createButtons.length).toBeGreaterThan(0); fireEvent.click(createButtons[0]); @@ -2503,8 +2571,7 @@ describe("ListView Quick Entry", () => { }); renderListView({ onQuickCreate: mockOnQuickCreate }); - const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement; - fireEvent.change(selector, { target: { value: "builtin:coding" } }); + await selectWorkflow("builtin:coding"); const input = screen.getByTestId("quick-entry-input"); fireEvent.change(input, { target: { value: "Built-in workflow task" } }); fireEvent.keyDown(input, { key: "Enter" }); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx new file mode 100644 index 0000000000..4a93f49c75 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { BoardWorkflowDefinition } from "../../api"; +import { WorkflowSwitcher } from "../WorkflowSwitcher"; +import type { WorkflowStatusCounts } from "../workflowStatusCounts"; + +const workflows: BoardWorkflowDefinition[] = [ + { + id: "coding", + name: "Coding", + columns: [], + }, + { + id: "design", + name: "Design", + columns: [], + }, +]; + +function countMap(entries: Array<[string, WorkflowStatusCounts]> = []) { + return new Map(entries); +} + +describe("WorkflowSwitcher", () => { + it("renders the active workflow with compact inline counts", () => { + render( + , + ); + + const trigger = screen.getByTestId("workflow-switcher"); + expect(trigger).toHaveTextContent("Coding"); + expect(trigger).toHaveTextContent("3"); + expect(trigger).toHaveTextContent("1"); + expect(trigger).toHaveTextContent("5"); + expect(trigger).toHaveAccessibleName("Select workflow. Current workflow: Coding"); + }); + + it("opens and closes the portaled listbox", () => { + render(); + + fireEvent.click(screen.getByTestId("workflow-switcher")); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + expect(screen.getByTestId("workflow-switcher-option-coding")).toHaveAttribute("aria-selected", "true"); + + fireEvent.mouseDown(document.body); + expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); + }); + + it("calls onChange when an option is selected", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId("workflow-switcher")); + fireEvent.click(screen.getByTestId("workflow-switcher-option-design")); + + expect(onChange).toHaveBeenCalledWith("design"); + expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); + }); + + it("supports keyboard navigation and escape dismissal", () => { + const onChange = vi.fn(); + render(); + + const trigger = screen.getByTestId("workflow-switcher"); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + fireEvent.keyDown(trigger, { key: "Enter" }); + + expect(onChange).toHaveBeenCalledWith("design"); + + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); + }); + + it("renders zero counts for workflows absent from the counts map", () => { + render(); + + fireEvent.click(screen.getByTestId("workflow-switcher")); + const designOption = screen.getByTestId("workflow-switcher-option-design"); + + expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--todo" })).toBeInTheDocument(); + expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--in-progress" })).toBeInTheDocument(); + expect(within(designOption).getByText("0", { selector: ".workflow-switcher-count--done" })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts b/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts new file mode 100644 index 0000000000..c93a42d2a4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { Task } from "@fusion/core"; +import type { BoardWorkflowsPayload } from "../../api"; +import { computeWorkflowStatusCounts } from "../workflowStatusCounts"; + +const boardWorkflows: BoardWorkflowsPayload = { + flagEnabled: true, + defaultWorkflowId: "default", + taskWorkflowIds: {}, + workflows: [ + { + id: "default", + name: "Default", + columns: [ + { id: "todo", name: "Todo", flags: { intake: true } }, + { id: "ready", name: "Ready", flags: {} }, + { id: "active", name: "Active", flags: { countsTowardWip: true } }, + { id: "review", name: "Review", flags: { countsTowardWip: true, mergeBlocker: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], + }, + { + id: "design", + name: "Design", + columns: [ + { id: "design-todo", name: "Todo", flags: { intake: true } }, + { id: "design-active", name: "Active", flags: { countsTowardWip: true } }, + { id: "design-done", name: "Done", flags: { complete: true } }, + { id: "design-archived", name: "Archived", flags: { archived: true } }, + ], + }, + { + id: "empty", + name: "Empty", + columns: [ + { id: "empty-todo", name: "Todo", flags: { intake: true } }, + { id: "empty-active", name: "Active", flags: { countsTowardWip: true } }, + { id: "empty-done", name: "Done", flags: { complete: true } }, + ], + }, + ], +}; + +function task(id: string, column: string): Task { + return { + id, + title: id, + description: id, + column, + dependencies: [], + steps: [], + currentStep: 0, + } as Task; +} + +describe("computeWorkflowStatusCounts", () => { + it("returns an empty map when workflow metadata is unavailable", () => { + expect(computeWorkflowStatusCounts([task("FN-1", "todo")], null).size).toBe(0); + expect(computeWorkflowStatusCounts(undefined, undefined).size).toBe(0); + }); + + it("initializes every workflow with zero counts for empty and duplicate/populated states", () => { + const counts = computeWorkflowStatusCounts([], boardWorkflows); + + expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 }); + expect(counts.get("design")).toEqual({ todo: 0, inProgress: 0, done: 0 }); + expect(counts.get("empty")).toEqual({ todo: 0, inProgress: 0, done: 0 }); + }); + + it("classifies todo, in-progress, and done buckets from workflow column flags", () => { + const counts = computeWorkflowStatusCounts( + [ + task("FN-todo", "todo"), + task("FN-ready", "ready"), + task("FN-active", "active"), + task("FN-review", "review"), + task("FN-done", "done"), + ], + boardWorkflows, + ); + + expect(counts.get("default")).toEqual({ todo: 2, inProgress: 2, done: 1 }); + }); + + it("falls back to the default workflow when a task has no workflow assignment", () => { + const counts = computeWorkflowStatusCounts([task("FN-unassigned", "done")], boardWorkflows); + + expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 1 }); + }); + + it("counts tasks independently for their assigned workflow", () => { + const counts = computeWorkflowStatusCounts( + [task("FN-design-todo", "design-todo"), task("FN-design-active", "design-active"), task("FN-design-done", "design-done")], + { + ...boardWorkflows, + taskWorkflowIds: { + "FN-design-todo": "design", + "FN-design-active": "design", + "FN-design-done": "design", + }, + }, + ); + + expect(counts.get("design")).toEqual({ todo: 1, inProgress: 1, done: 1 }); + expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 }); + }); + + it("excludes archived-column tasks and ignores unknown workflows or columns", () => { + const counts = computeWorkflowStatusCounts( + [task("FN-archived", "archived"), task("FN-unknown-column", "missing"), task("FN-unknown-workflow", "todo")], + { + ...boardWorkflows, + taskWorkflowIds: { + "FN-unknown-workflow": "missing-workflow", + }, + }, + ); + + expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0 }); + }); +}); diff --git a/packages/dashboard/app/components/workflowStatusCounts.ts b/packages/dashboard/app/components/workflowStatusCounts.ts new file mode 100644 index 0000000000..d18f5dee0d --- /dev/null +++ b/packages/dashboard/app/components/workflowStatusCounts.ts @@ -0,0 +1,54 @@ +import type { Task } from "@fusion/core"; +import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../api"; + +export interface WorkflowStatusCounts { + todo: number; + inProgress: number; + done: number; +} + +const EMPTY_COUNTS = (): WorkflowStatusCounts => ({ todo: 0, inProgress: 0, done: 0 }); + +/** + * FNXC:WorkflowSwitcher 2026-06-20-00:09: + * The board/list workflow dropdown must show compact Todo, In Progress, and Done task counts for every selectable workflow without duplicating logic across render surfaces. + * Use workflow column flags as the source of truth: archived columns are excluded, complete columns count as Done, active non-intake WIP columns count as In Progress, and all remaining visible work counts as Todo/not-yet-started. + */ +export function computeWorkflowStatusCounts( + tasks: readonly Task[] | null | undefined, + boardWorkflows: BoardWorkflowsPayload | null | undefined, +): Map { + const countsByWorkflow = new Map(); + if (!boardWorkflows) return countsByWorkflow; + + const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow])); + const columnsByWorkflowId = new Map>(); + + for (const workflow of boardWorkflows.workflows) { + countsByWorkflow.set(workflow.id, EMPTY_COUNTS()); + columnsByWorkflowId.set(workflow.id, new Map(workflow.columns.map((column) => [column.id, column]))); + } + + if (!tasks?.length) return countsByWorkflow; + + for (const task of tasks) { + const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId; + const workflow = workflowsById.get(workflowId); + if (!workflow) continue; + + const column = columnsByWorkflowId.get(workflow.id)?.get(task.column); + if (!column || column.flags.archived) continue; + + const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS(); + if (column.flags.complete) { + counts.done += 1; + } else if (column.flags.countsTowardWip && !column.flags.intake) { + counts.inProgress += 1; + } else { + counts.todo += 1; + } + countsByWorkflow.set(workflow.id, counts); + } + + return countsByWorkflow; +} diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 1561255b9b..675a073e78 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1869,6 +1869,7 @@ "title": "Create room" }, "dashboard": { + "brandName": "Fusion", "initializingDashboard": "Initializing dashboard...", "loadingMessage": "Loading Fusion dashboard", "loadingProgress": "Dashboard loading progress", @@ -7417,6 +7418,14 @@ "templateSectionBuiltin": "Built-in workflows", "templateSectionYours": "Your workflows" }, + "workflowSwitcher": { + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "done": "Done", + "inProgress": "In Progress", + "label": "Workflow", + "todo": "Todo", + "triggerAria": "Select workflow. Current workflow: {{name}}" + }, "workflowSelector": { "switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", "switchActiveTitle": "Switch workflow?", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 07e24aa7ba..4284d55a9c 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1869,6 +1869,7 @@ "title": "Crear sala" }, "dashboard": { + "brandName": "Fusion", "initializingDashboard": "Inicializando panel...", "loadingMessage": "Cargando panel de Fusion", "loadingProgress": "Progreso de carga del panel", @@ -7417,6 +7418,14 @@ "templateSectionBuiltin": "Flujos de trabajo integrados", "templateSectionYours": "Tus flujos de trabajo" }, + "workflowSwitcher": { + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "done": "Done", + "inProgress": "In Progress", + "label": "Workflow", + "todo": "Todo", + "triggerAria": "Select workflow. Current workflow: {{name}}" + }, "workflowSelector": { "switchActiveMessage": "", "switchActiveTitle": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 1ed48a43fa..f57d82347b 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1869,6 +1869,7 @@ "title": "Créer une salle" }, "dashboard": { + "brandName": "Fusion", "initializingDashboard": "Initialisation du tableau de bord...", "loadingMessage": "Chargement du tableau de bord Fusion", "loadingProgress": "Progression du chargement du tableau de bord", @@ -7417,6 +7418,14 @@ "templateSectionBuiltin": "Workflows intégrés", "templateSectionYours": "Vos workflows" }, + "workflowSwitcher": { + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "done": "Done", + "inProgress": "In Progress", + "label": "Workflow", + "todo": "Todo", + "triggerAria": "Select workflow. Current workflow: {{name}}" + }, "workflowSelector": { "switchActiveMessage": "", "switchActiveTitle": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 9149e65ce8..8ae6056e12 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1869,6 +1869,7 @@ "title": "방 만들기" }, "dashboard": { + "brandName": "Fusion", "initializingDashboard": "대시보드 초기화 중...", "loadingMessage": "Fusion 대시보드 로드 중", "loadingProgress": "대시보드 로딩 진행 상황", @@ -7417,6 +7418,14 @@ "templateSectionBuiltin": "기본 제공 워크플로", "templateSectionYours": "내 워크플로" }, + "workflowSwitcher": { + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "done": "Done", + "inProgress": "In Progress", + "label": "Workflow", + "todo": "Todo", + "triggerAria": "Select workflow. Current workflow: {{name}}" + }, "workflowSelector": { "switchActiveMessage": "", "switchActiveTitle": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index cf79840d67..050a2eaf45 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1869,6 +1869,7 @@ "title": "创建房间" }, "dashboard": { + "brandName": "Fusion", "initializingDashboard": "初始化仪表板...", "loadingMessage": "加载 Fusion 仪表板", "loadingProgress": "仪表板加载进度", @@ -7417,6 +7418,14 @@ "templateSectionBuiltin": "内置工作流", "templateSectionYours": "我的工作流" }, + "workflowSwitcher": { + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "done": "Done", + "inProgress": "In Progress", + "label": "Workflow", + "todo": "Todo", + "triggerAria": "Select workflow. Current workflow: {{name}}" + }, "workflowSelector": { "switchActiveMessage": "", "switchActiveTitle": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 084960c08b..6488b76c2b 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1869,6 +1869,7 @@ "title": "建立房間" }, "dashboard": { + "brandName": "Fusion", "initializingDashboard": "初始化儀表板...", "loadingMessage": "載入 Fusion 儀表板", "loadingProgress": "儀表板加載進度", @@ -7417,6 +7418,14 @@ "templateSectionBuiltin": "內建工作流程", "templateSectionYours": "你的工作流程" }, + "workflowSwitcher": { + "countsAria": "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", + "done": "Done", + "inProgress": "In Progress", + "label": "Workflow", + "todo": "Todo", + "triggerAria": "Select workflow. Current workflow: {{name}}" + }, "workflowSelector": { "switchActiveMessage": "", "switchActiveTitle": "",