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
This commit is contained in:
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) && (
|
||||
<div className="board-workflow-toolbar">
|
||||
{workflowOptions.length > 1 && (
|
||||
<label className="list-workflow-selector board-workflow-selector">
|
||||
<span>Workflow</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
<div className="board-workflow-selector">
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
onChange={(event) => setSelectedWorkflowId(event.target.value)}
|
||||
aria-label="Select workflow"
|
||||
>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={workflowStatusCounts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onOpenWorkflowEditor && (
|
||||
<button
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
}
|
||||
|
||||
.board-workflow-selector {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.board-workflow-create-btn,
|
||||
|
||||
@@ -412,7 +412,7 @@ export function LeftSidebarNav({
|
||||
>
|
||||
<div className="left-sidebar-nav__brand" data-testid="sidebar-nav-brand">
|
||||
<FusionLogo />
|
||||
<span className="left-sidebar-nav__wordmark">Fusion</span>
|
||||
<span className="left-sidebar-nav__wordmark">{t("dashboard.brandName", "Fusion")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon left-sidebar-nav__collapse-toggle"
|
||||
|
||||
@@ -39,23 +39,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-workflow-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
}
|
||||
|
||||
.list-workflow-select {
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
min-height: 30px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.list-sidebar-controls__actions .list-new-task-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -628,6 +630,11 @@ export function ListView({
|
||||
return ids;
|
||||
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
|
||||
|
||||
const workflowStatusCounts = useMemo(
|
||||
() => computeWorkflowStatusCounts(tasks, boardWorkflows),
|
||||
[boardWorkflows, tasks],
|
||||
);
|
||||
|
||||
const createTargetColumn = useMemo(() => {
|
||||
const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
|
||||
?? listColumns.find((column) => !column.flags.archived);
|
||||
@@ -1578,22 +1585,14 @@ export function ListView({
|
||||
if (!showSelect && !onCreateWorkflow) return null;
|
||||
return (
|
||||
<div className="list-workflow-control">
|
||||
{showSelect && (
|
||||
<label className="list-workflow-selector">
|
||||
<span>{t("listView.workflowLabel", "Workflow")}</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
value={selectedWorkflow!.id}
|
||||
onChange={(event) => setSelectedWorkflowId(event.target.value)}
|
||||
aria-label={t("listView.workflowSelectLabel", "Select workflow")}
|
||||
>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{showSelect && selectedWorkflow && (
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={workflowStatusCounts}
|
||||
label={t("listView.workflowLabel", "Workflow")}
|
||||
/>
|
||||
)}
|
||||
{onCreateWorkflow && (
|
||||
<button
|
||||
|
||||
186
packages/dashboard/app/components/WorkflowSwitcher.css
Normal file
186
packages/dashboard/app/components/WorkflowSwitcher.css
Normal file
@@ -0,0 +1,186 @@
|
||||
.workflow-switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
}
|
||||
|
||||
.workflow-switcher-label {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workflow-switcher-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
min-width: calc(var(--space-xl) * 7.5);
|
||||
max-width: calc(var(--space-xl) * 12);
|
||||
min-height: calc(var(--space-lg) + var(--space-sm));
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workflow-switcher-trigger:hover,
|
||||
.workflow-switcher-trigger[aria-expanded="true"] {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.workflow-switcher-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.workflow-switcher-trigger-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.workflow-switcher-current-name,
|
||||
.workflow-switcher-option-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-switcher-current-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.workflow-switcher-chevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.workflow-switcher-trigger[aria-expanded="true"] .workflow-switcher-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.workflow-switcher-counts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workflow-switcher-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(var(--space-md) + var(--space-xs));
|
||||
padding: 0 var(--space-xs);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
line-height: calc(var(--space-md) / var(--space-sm));
|
||||
}
|
||||
|
||||
.workflow-switcher-count--todo {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workflow-switcher-count--in-progress {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.workflow-switcher-count--done {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.workflow-switcher-count-separator {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.workflow-switcher-menu {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.workflow-switcher-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.workflow-switcher-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workflow-switcher-option:hover,
|
||||
.workflow-switcher-option--highlighted {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.workflow-switcher-option--selected {
|
||||
background: color-mix(in srgb, var(--todo) 15%, transparent);
|
||||
}
|
||||
|
||||
.workflow-switcher-option--selected:hover,
|
||||
.workflow-switcher-option--selected.workflow-switcher-option--highlighted {
|
||||
background: color-mix(in srgb, var(--todo) 25%, transparent);
|
||||
}
|
||||
|
||||
.workflow-switcher-option-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.workflow-switcher-counts--option {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workflow-switcher {
|
||||
flex: 1 1 auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workflow-switcher-label {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.workflow-switcher-trigger {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-switcher-menu {
|
||||
max-width: calc(100vw - var(--space-xl));
|
||||
}
|
||||
}
|
||||
270
packages/dashboard/app/components/WorkflowSwitcher.tsx
Normal file
270
packages/dashboard/app/components/WorkflowSwitcher.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import "./WorkflowSwitcher.css";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BoardWorkflowDefinition } from "../api";
|
||||
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
|
||||
export interface WorkflowSwitcherProps {
|
||||
workflows: BoardWorkflowDefinition[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
counts: Map<string, WorkflowStatusCounts>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0 };
|
||||
|
||||
function getCounts(counts: Map<string, WorkflowStatusCounts>, workflowId: string): WorkflowStatusCounts {
|
||||
return counts.get(workflowId) ?? ZERO_COUNTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowSwitcher 2026-06-20-00:09:
|
||||
* The board/list workflow switcher must be a fully rendered themed dropdown rather than a native select so each workflow option can include compact inline Todo, In Progress, and Done counts.
|
||||
* The component owns only presentation and accessible dropdown behavior; all status-bucket semantics stay in computeWorkflowStatusCounts so Board and ListView cannot drift.
|
||||
*/
|
||||
export function WorkflowSwitcher({ workflows, value, onChange, counts, label: labelProp }: WorkflowSwitcherProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const label = labelProp ?? t("workflowSwitcher.label", "Workflow");
|
||||
const todoLabel = t("workflowSwitcher.todo", "Todo");
|
||||
const inProgressLabel = t("workflowSwitcher.inProgress", "In Progress");
|
||||
const doneLabel = t("workflowSwitcher.done", "Done");
|
||||
const listboxId = useId();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedIndex = useMemo(() => Math.max(0, workflows.findIndex((workflow) => workflow.id === value)), [value, workflows]);
|
||||
const selectedWorkflow = workflows[selectedIndex] ?? workflows[0] ?? null;
|
||||
const selectedCounts = selectedWorkflow ? getCounts(counts, selectedWorkflow.id) : ZERO_COUNTS;
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
const offsetTop = window.visualViewport?.offsetTop ?? 0;
|
||||
const offsetLeft = window.visualViewport?.offsetLeft ?? 0;
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 4;
|
||||
const preferredHeight = Math.min(viewportHeight * 0.6, 320);
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
const availableHeight = Math.max((openUpward ? spaceAbove : spaceBelow) - verticalPadding - gap, 160);
|
||||
const maxHeight = Math.max(Math.min(availableHeight, preferredHeight), 160);
|
||||
const width = Math.min(Math.max(rect.width, 240), viewportWidth - horizontalPadding * 2);
|
||||
const left = Math.min(Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width) + offsetLeft;
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
|
||||
|
||||
setDropdownPosition({ top, left, width, maxHeight });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPortalRoot(document.body);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setHighlightedIndex(selectedIndex);
|
||||
updateDropdownPosition();
|
||||
}, [isOpen, selectedIndex, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleReposition = () => updateDropdownPosition();
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
const visualViewport = window.visualViewport;
|
||||
visualViewport?.addEventListener("resize", handleReposition);
|
||||
visualViewport?.addEventListener("scroll", handleReposition);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
visualViewport?.removeEventListener("resize", handleReposition);
|
||||
visualViewport?.removeEventListener("scroll", handleReposition);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !listRef.current) return;
|
||||
const highlightedElement = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
|
||||
if (highlightedElement && typeof highlightedElement.scrollIntoView === "function") {
|
||||
highlightedElement.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [highlightedIndex, isOpen]);
|
||||
|
||||
const selectWorkflow = useCallback((workflowId: string) => {
|
||||
onChange(workflowId);
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}, [onChange]);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setHighlightedIndex((current) => (workflows.length ? (current + 1) % workflows.length : 0));
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setHighlightedIndex((current) => (workflows.length ? (current - 1 + workflows.length) % workflows.length : 0));
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (isOpen) {
|
||||
const workflow = workflows[highlightedIndex];
|
||||
if (workflow) selectWorkflow(workflow.id);
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
setIsOpen(false);
|
||||
break;
|
||||
case "Tab":
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [highlightedIndex, isOpen, selectWorkflow, workflows]);
|
||||
|
||||
if (!selectedWorkflow) return null;
|
||||
|
||||
const renderCountBadges = (workflowCounts: WorkflowStatusCounts, variant: "trigger" | "option") => (
|
||||
<span className={`workflow-switcher-counts workflow-switcher-counts--${variant}`} aria-hidden="true">
|
||||
<span className="workflow-switcher-count workflow-switcher-count--todo" title={`${todoLabel}: ${workflowCounts.todo}`}>{workflowCounts.todo}</span>
|
||||
<span className="workflow-switcher-count-separator">·</span>
|
||||
<span className="workflow-switcher-count workflow-switcher-count--in-progress" title={`${inProgressLabel}: ${workflowCounts.inProgress}`}>{workflowCounts.inProgress}</span>
|
||||
<span className="workflow-switcher-count-separator">·</span>
|
||||
<span className="workflow-switcher-count workflow-switcher-count--done" title={`${doneLabel}: ${workflowCounts.done}`}>{workflowCounts.done}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderAccessibleCounts = (workflowCounts: WorkflowStatusCounts) => (
|
||||
<span className="visually-hidden">
|
||||
{t("workflowSwitcher.countsAria", "{{todoLabel}}: {{todo}}, {{inProgressLabel}}: {{inProgress}}, {{doneLabel}}: {{done}}", {
|
||||
todoLabel,
|
||||
todo: workflowCounts.todo,
|
||||
inProgressLabel,
|
||||
inProgress: workflowCounts.inProgress,
|
||||
doneLabel,
|
||||
done: workflowCounts.done,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
|
||||
const dropdown = isOpen && portalRoot && dropdownPosition
|
||||
? createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id={listboxId}
|
||||
className="workflow-switcher-menu"
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
maxHeight: dropdownPosition.maxHeight,
|
||||
}}
|
||||
>
|
||||
<div ref={listRef} className="workflow-switcher-options">
|
||||
{workflows.map((workflow, index) => {
|
||||
const workflowCounts = getCounts(counts, workflow.id);
|
||||
const isSelected = workflow.id === selectedWorkflow.id;
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
data-index={index}
|
||||
data-testid={`workflow-switcher-option-${workflow.id}`}
|
||||
className={`workflow-switcher-option${isSelected ? " workflow-switcher-option--selected" : ""}${isHighlighted ? " workflow-switcher-option--highlighted" : ""}`}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => selectWorkflow(workflow.id)}
|
||||
>
|
||||
<span className="workflow-switcher-option-name">{workflow.name}</span>
|
||||
{renderCountBadges(workflowCounts, "option")}
|
||||
{renderAccessibleCounts(workflowCounts)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="workflow-switcher">
|
||||
<span className="workflow-switcher-label">{label}</span>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="btn workflow-switcher-trigger"
|
||||
data-testid="workflow-switcher"
|
||||
aria-haspopup="listbox"
|
||||
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)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span className="workflow-switcher-trigger-main">
|
||||
<span className="workflow-switcher-current-name">{selectedWorkflow.name}</span>
|
||||
{renderCountBadges(selectedCounts, "trigger")}
|
||||
{renderAccessibleCounts(selectedCounts)}
|
||||
</span>
|
||||
<ChevronDown className="workflow-switcher-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
{dropdown}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,17 @@ function renderBoard(props = {}) {
|
||||
return render(<Board {...createBoardProps(props)} />);
|
||||
}
|
||||
|
||||
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 <main> 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"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,6 +251,17 @@ const keyDownAndFlush = async (element: Element, init: Parameters<typeof fireEve
|
||||
});
|
||||
};
|
||||
|
||||
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}`));
|
||||
}
|
||||
|
||||
const enterBulkEditMode = () => {
|
||||
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" });
|
||||
|
||||
@@ -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<string, WorkflowStatusCounts>(entries);
|
||||
}
|
||||
|
||||
describe("WorkflowSwitcher", () => {
|
||||
it("renders the active workflow with compact inline counts", () => {
|
||||
render(
|
||||
<WorkflowSwitcher
|
||||
workflows={workflows}
|
||||
value="coding"
|
||||
onChange={vi.fn()}
|
||||
counts={countMap([["coding", { todo: 3, inProgress: 1, done: 5 }]])}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} />);
|
||||
|
||||
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(<WorkflowSwitcher workflows={workflows} value="coding" onChange={onChange} counts={countMap()} />);
|
||||
|
||||
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(<WorkflowSwitcher workflows={workflows} value="coding" onChange={onChange} counts={countMap()} />);
|
||||
|
||||
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(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
54
packages/dashboard/app/components/workflowStatusCounts.ts
Normal file
54
packages/dashboard/app/components/workflowStatusCounts.ts
Normal file
@@ -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<string, WorkflowStatusCounts> {
|
||||
const countsByWorkflow = new Map<string, WorkflowStatusCounts>();
|
||||
if (!boardWorkflows) return countsByWorkflow;
|
||||
|
||||
const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
|
||||
const columnsByWorkflowId = new Map<string, Map<string, BoardWorkflowColumn>>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user