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:
gsxdsm
2026-06-20 02:08:36 -07:00
parent 1ab2d4161a
commit 7eceec00cc
19 changed files with 930 additions and 70 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -39,7 +39,9 @@
}
.board-workflow-selector {
display: inline-flex;
margin-left: auto;
min-width: 0;
}
.board-workflow-create-btn,

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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

View 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));
}
}

View 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>
);
}

View File

@@ -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"]));
});
});

View File

@@ -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" });

View File

@@ -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();
});
});

View File

@@ -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 });
});
});

View 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;
}

View File

@@ -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?",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",