From 2f7905386f8ff782094895d7316216493cfc7ce2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 11:41:13 -0700 Subject: [PATCH] FN-6848: refresh workflow dropdown counts on open Refresh workflow dropdown counts from the latest board-workflows data whenever users open the selector. - Refactor Board and ListView workflow payload fetching into reusable refresh callbacks shared by mount, visibility, focus, SSE, and dropdown-open events. - Add a WorkflowSwitcher onOpen hook that fires only on closed-to-open click or keyboard transitions. - Cover Board, ListView, and WorkflowSwitcher refresh behavior with regression tests and document the refreshed dropdown counts. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/Board.tsx | 49 +++++++++++--------- packages/dashboard/app/components/ListView.tsx | 49 +++++++++++--------- .../dashboard/app/components/WorkflowSwitcher.tsx | 38 +++++++++++++--- .../app/components/__tests__/Board.test.tsx | 14 ++++++ .../app/components/__tests__/ListView.test.tsx | 39 ++++++++++++++++ .../components/__tests__/WorkflowSwitcher.test.tsx | 53 ++++++++++++++++++++++ 7 files changed, 195 insertions(+), 49 deletions(-) Fusion-Task-Id: FN-6848 Fusion-Task-Lineage: bb83cee9-4184-4094-89d6-f2cf2a4fbd46 --- docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/Board.tsx | 49 +++++++++-------- .../dashboard/app/components/ListView.tsx | 49 +++++++++-------- .../app/components/WorkflowSwitcher.tsx | 38 ++++++++++--- .../app/components/__tests__/Board.test.tsx | 14 +++++ .../components/__tests__/ListView.test.tsx | 39 ++++++++++++++ .../__tests__/WorkflowSwitcher.test.tsx | 53 +++++++++++++++++++ 7 files changed, 195 insertions(+), 49 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5061f8464b..8b01261393 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -79,7 +79,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 shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`. +- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`. - When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. ![Board view](./screenshots/dashboard-overview.png) diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 7111955b5c..5bf36b11b8 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -390,34 +390,40 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); }, [projectId, shouldHydrateBoardWorkflowsCache]); + /* + FNXC:WorkflowControls 2026-06-21-00:00: + Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events. + Share this path with mount, visibility/focus, and workflow-definition SSE refetches so the stale-response guard and cache writes remain identical. + */ + const refreshBoardWorkflows = useCallback(() => { + const seq = ++boardWorkflowsFetchSeqRef.current; + fetchBoardWorkflows(projectId) + .then((payload) => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } + }) + .catch(() => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); + } + }); + }, [projectId]); + useEffect(() => { - const runFetch = () => { - const seq = ++boardWorkflowsFetchSeqRef.current; - fetchBoardWorkflows(projectId) - .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflowsState({ projectId, payload }); - writeBoardWorkflowsCache(projectId, payload); - } - }) - .catch(() => { - if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); - } - }); - }; - runFetch(); + refreshBoardWorkflows(); const onVisible = () => { - if (typeof document === "undefined" || document.visibilityState === "visible") runFetch(); + if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows(); }; if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); if (typeof window !== "undefined") window.addEventListener("focus", onVisible); const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const unsubscribe = subscribeSse(`/api/events${query}`, { events: { - "workflow:created": runFetch, - "workflow:updated": runFetch, - "workflow:deleted": runFetch, + "workflow:created": refreshBoardWorkflows, + "workflow:updated": refreshBoardWorkflows, + "workflow:deleted": refreshBoardWorkflows, }, }); return () => { @@ -427,7 +433,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); unsubscribe(); }; - }, [projectId]); + }, [projectId, refreshBoardWorkflows]); const handlePromote = useCallback(async (taskId: string) => { await promoteTask(taskId, projectId); @@ -576,6 +582,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask value={selectedWorkflow.id} onChange={setSelectedWorkflowId} counts={workflowStatusCounts} + onOpen={refreshBoardWorkflows} onEditWorkflow={onOpenWorkflowEditor} onCreateWorkflow={onCreateWorkflow} /> diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index a1a3c9c857..e0eba2446b 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -430,34 +430,40 @@ export function ListView({ setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); }, [projectId, shouldHydrateBoardWorkflowsCache]); + /* + FNXC:WorkflowControls 2026-06-21-00:00: + Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events. + Share this path with mount, visibility/focus, and workflow-definition SSE refetches so desktop sidebar and mobile toolbar counts cannot drift. + */ + const refreshBoardWorkflows = useCallback(() => { + const seq = ++boardWorkflowsFetchSeqRef.current; + fetchBoardWorkflows(projectId) + .then((payload) => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } + }) + .catch(() => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); + } + }); + }, [projectId]); + useEffect(() => { - const runFetch = () => { - const seq = ++boardWorkflowsFetchSeqRef.current; - fetchBoardWorkflows(projectId) - .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflowsState({ projectId, payload }); - writeBoardWorkflowsCache(projectId, payload); - } - }) - .catch(() => { - if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); - } - }); - }; - runFetch(); + refreshBoardWorkflows(); const onVisible = () => { - if (typeof document === "undefined" || document.visibilityState === "visible") runFetch(); + if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows(); }; if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); if (typeof window !== "undefined") window.addEventListener("focus", onVisible); const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const unsubscribe = subscribeSse(`/api/events${query}`, { events: { - "workflow:created": runFetch, - "workflow:updated": runFetch, - "workflow:deleted": runFetch, + "workflow:created": refreshBoardWorkflows, + "workflow:updated": refreshBoardWorkflows, + "workflow:deleted": refreshBoardWorkflows, }, }); return () => { @@ -466,7 +472,7 @@ export function ListView({ if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); unsubscribe(); }; - }, [projectId]); + }, [projectId, refreshBoardWorkflows]); // Persist selection to localStorage useEffect(() => { @@ -1630,6 +1636,7 @@ export function ListView({ value={selectedWorkflow.id} onChange={setSelectedWorkflowId} counts={workflowStatusCounts} + onOpen={refreshBoardWorkflows} label={t("listView.workflowLabel", "Workflow")} onEditWorkflow={onOpenWorkflowEditor} onCreateWorkflow={onCreateWorkflow} diff --git a/packages/dashboard/app/components/WorkflowSwitcher.tsx b/packages/dashboard/app/components/WorkflowSwitcher.tsx index 2d6fc36e34..b204797e19 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.tsx +++ b/packages/dashboard/app/components/WorkflowSwitcher.tsx @@ -12,6 +12,8 @@ export interface WorkflowSwitcherProps { value: string; onChange: (id: string) => void; counts: Map; + /** Fired each time the dropdown transitions from closed to open so consumers can refresh count data. */ + onOpen?: () => void; label?: string; onEditWorkflow?: (workflowId: string) => void; onCreateWorkflow?: () => void; @@ -42,8 +44,12 @@ function getCounts(counts: Map, workflowId: string * FNXC:WorkflowSwitcher 2026-06-20-15:34: * Workflow edit and creation affordances moved into the shared dropdown so Board and ListView cannot leave separate toolbar icon shells behind. * Each option row owns a sibling edit button, and New workflow remains visible in a non-scrolling footer while long workflow lists scroll. + * + * FNXC:WorkflowSwitcher 2026-06-21-00:00: + * Opening the dropdown must refresh workflow count data because task-to-workflow assignments do not emit board-workflows invalidation events. + * Fire onOpen only on closed-to-open transitions so consumers can refetch without close-time calls or render loops. */ -export function WorkflowSwitcher({ workflows, value, onChange, counts, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) { +export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) { const { t } = useTranslation("app"); const label = labelProp ?? t("workflowSwitcher.label", "Workflow"); const todoLabel = t("workflowSwitcher.todo", "Todo"); @@ -62,6 +68,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la const triggerRef = useRef(null); const dropdownRef = useRef(null); const listRef = useRef(null); + const onOpenRef = useRef(onOpen); const selectedIndex = useMemo(() => Math.max(0, workflows.findIndex((workflow) => workflow.id === value)), [value, workflows]); const selectedWorkflow = workflows[selectedIndex] ?? workflows[0] ?? null; @@ -96,6 +103,10 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la setDropdownPosition({ top, left, width, maxHeight }); }, []); + useEffect(() => { + onOpenRef.current = onOpen; + }, [onOpen]); + useEffect(() => { setPortalRoot(document.body); }, []); @@ -141,6 +152,21 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la } }, [highlightedIndex, isOpen]); + const openDropdown = useCallback(() => { + if (isOpen) return; + onOpenRef.current?.(); + setIsOpen(true); + }, [isOpen]); + + const toggleDropdown = useCallback(() => { + if (isOpen) { + setIsOpen(false); + return; + } + onOpenRef.current?.(); + setIsOpen(true); + }, [isOpen]); + const selectWorkflow = useCallback((workflowId: string) => { onChange(workflowId); setIsOpen(false); @@ -164,7 +190,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la case "ArrowDown": event.preventDefault(); if (!isOpen) { - setIsOpen(true); + openDropdown(); } else { setHighlightedIndex((current) => (workflows.length ? (current + 1) % workflows.length : 0)); } @@ -172,7 +198,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la case "ArrowUp": event.preventDefault(); if (!isOpen) { - setIsOpen(true); + openDropdown(); } else { setHighlightedIndex((current) => (workflows.length ? (current - 1 + workflows.length) % workflows.length : 0)); } @@ -184,7 +210,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la const workflow = workflows[highlightedIndex]; if (workflow) selectWorkflow(workflow.id); } else { - setIsOpen(true); + openDropdown(); } break; case "Escape": @@ -195,7 +221,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la setIsOpen(false); break; } - }, [highlightedIndex, isOpen, selectWorkflow, workflows]); + }, [highlightedIndex, isOpen, openDropdown, selectWorkflow, workflows]); if (!selectedWorkflow) return null; @@ -310,7 +336,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, label: la aria-expanded={isOpen} aria-controls={isOpen ? listboxId : undefined} aria-label={t("workflowSwitcher.triggerAria", "Select workflow. Current workflow: {{name}}", { name: selectedWorkflow.name })} - onClick={() => setIsOpen((open) => !open)} + onClick={toggleDropdown} onKeyDown={handleKeyDown} > diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 8cdcf50e3a..a33e23bb8f 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -1300,6 +1300,20 @@ describe("Board", () => { expect(screen.getByTestId("column-done").getAttribute("data-has-archive-all")).toBe("yes"); expect(screen.getByTestId("column-todo").getAttribute("data-has-archive-all")).toBe("no"); }); + + it("re-fetches board-workflows when the workflow switcher opens", async () => { + enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ projectId: "proj-1", tasks: [mkTask({ id: "FN-1", column: "todo" })] }); + + const trigger = await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1)); + fetchBoardWorkflowsMock.mockClear(); + + fireEvent.click(trigger); + + expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + }); }); describe("workflow:updated SSE invalidation (#1406)", () => { diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 18df7d3e3b..36a8e1d2c9 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -658,6 +658,45 @@ describe("ListView", () => { expect(screen.queryAllByText("Backlog")).toHaveLength(0); }); + it("re-fetches board-workflows when the workflow switcher opens", async () => { + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "todo", name: "Todo", flags: { hold: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }, + { + id: "wf-custom", + name: "Custom Flow", + columns: [ + { id: "backlog", name: "Backlog", flags: { intake: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }, + ], + taskWorkflowIds: { "FN-001": "builtin:coding" }, + }); + + renderListView({ + tasks: [createMockTask({ id: "FN-001", column: "todo", title: "Workflow task" })], + }); + + const trigger = await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalledTimes(1)); + vi.mocked(fetchBoardWorkflows).mockClear(); + + fireEvent.click(trigger); + + expect(fetchBoardWorkflows).toHaveBeenCalledTimes(1); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + }); + it("re-homes a preserved-column task to the new workflow after workflow invalidation", async () => { const preservedWorkflow = { id: "wf-preserved", diff --git a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx index 8abba3fd5c..26bb97851d 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx @@ -58,6 +58,59 @@ describe("WorkflowSwitcher", () => { expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); }); + it("fires onOpen only on click-driven closed-to-open transitions", () => { + const onOpen = vi.fn(); + render(); + + const trigger = screen.getByTestId("workflow-switcher"); + expect(onOpen).not.toHaveBeenCalled(); + + fireEvent.click(trigger); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + + fireEvent.click(trigger); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); + + fireEvent.click(trigger); + expect(onOpen).toHaveBeenCalledTimes(2); + }); + + it.each(["ArrowDown", "ArrowUp", "Enter", " "])("fires onOpen when %s opens the dropdown from the keyboard", (key) => { + const onOpen = vi.fn(); + render(); + + const trigger = screen.getByTestId("workflow-switcher"); + expect(onOpen).not.toHaveBeenCalled(); + + fireEvent.keyDown(trigger, { key }); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument(); + }); + + it("does not fire onOpen when Escape or outside mousedown closes and fires again after reopening", () => { + const onOpen = vi.fn(); + render(); + + const trigger = screen.getByTestId("workflow-switcher"); + fireEvent.click(trigger); + expect(onOpen).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); + + fireEvent.click(trigger); + expect(onOpen).toHaveBeenCalledTimes(2); + fireEvent.mouseDown(document.body); + expect(onOpen).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument(); + + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + expect(onOpen).toHaveBeenCalledTimes(3); + }); + it("calls onChange when an option is selected", () => { const onChange = vi.fn(); render();