FN-6046: add collapsible workflow selectors
Add collapsible workflow selectors for board workflow controls and inline task creation. - add a collapsible board workflow toolbar with persisted per-project state - make the inline create workflow selector collapsible and store its expanded state per project - update workflow selector styling, storage key allowlist, and regression coverage for collapse behavior Files changed: packages/dashboard/app/components/Board.tsx | 104 +++++++++++++-------- .../dashboard/app/components/InlineCreateCard.css | 9 ++ .../dashboard/app/components/InlineCreateCard.tsx | 3 + packages/dashboard/app/components/Lane.css | 23 +++++ .../dashboard/app/components/WorkflowSelector.css | 20 +++- .../dashboard/app/components/WorkflowSelector.tsx | 81 ++++++++++++++-- .../app/components/__tests__/Board.test.tsx | 101 ++++++++++++++++++-- .../components/__tests__/InlineCreateCard.test.tsx | 57 ++++++++++- .../components/__tests__/WorkflowSelector.test.tsx | 73 ++++++++++++++- .../app/utils/__tests__/projectStorage.test.ts | 4 +- packages/dashboard/app/utils/projectStorage.ts | 2 + 11 files changed, 420 insertions(+), 57 deletions(-) Fusion-Task-Id: FN-6046 Fusion-Task-Lineage: 07cbe7a8-5740-479d-94a3-b178c72a5273
This commit is contained in:
@@ -5,11 +5,12 @@ import { Column } from "./Column";
|
||||
import "./Lane.css";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { Pencil, Plus } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Pencil, Plus } from "lucide-react";
|
||||
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
|
||||
@@ -92,6 +93,7 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
|
||||
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const [workflowToolbarCollapsed, setWorkflowToolbarCollapsed] = useState<boolean>(() => getScopedItem("kb-dashboard-board-workflow-collapsed", projectId) === "1");
|
||||
const archivedLoadedRef = useRef(false);
|
||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
||||
const boardRef = useRef<HTMLElement | null>(null);
|
||||
@@ -109,6 +111,12 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
archived: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setScopedItem("kb-dashboard-board-workflow-collapsed", workflowToolbarCollapsed ? "1" : "0", projectId);
|
||||
}
|
||||
}, [workflowToolbarCollapsed, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
recordResumeEvent({
|
||||
view: "Board",
|
||||
@@ -461,45 +469,61 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{(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"
|
||||
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>
|
||||
)}
|
||||
{onOpenWorkflowEditor && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-edit-btn"
|
||||
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
|
||||
title="Edit workflows"
|
||||
aria-label="Edit workflows"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
{onCreateWorkflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-create-btn"
|
||||
onClick={onCreateWorkflow}
|
||||
title="New workflow"
|
||||
aria-label="New workflow"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<div className="board-workflow-toolbar" data-collapsed={workflowToolbarCollapsed || undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-collapse-toggle"
|
||||
onClick={() => setWorkflowToolbarCollapsed((c) => !c)}
|
||||
aria-expanded={!workflowToolbarCollapsed}
|
||||
aria-label={workflowToolbarCollapsed ? "Expand workflow toolbar" : "Collapse workflow toolbar"}
|
||||
data-testid="board-workflow-collapse-toggle"
|
||||
>
|
||||
{workflowToolbarCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{workflowToolbarCollapsed ? (
|
||||
<span className="board-workflow-collapsed-label">Workflow</span>
|
||||
) : (
|
||||
<>
|
||||
{workflowOptions.length > 1 && (
|
||||
<label className="list-workflow-selector board-workflow-selector">
|
||||
<span>Workflow</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
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>
|
||||
)}
|
||||
{onOpenWorkflowEditor && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-edit-btn"
|
||||
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
|
||||
title="Edit workflows"
|
||||
aria-label="Edit workflows"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
{onCreateWorkflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-create-btn"
|
||||
onClick={onCreateWorkflow}
|
||||
title="New workflow"
|
||||
aria-label="New workflow"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -141,6 +141,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inline-create-controls .workflow-selector {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inline-create-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -288,6 +293,10 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.inline-create-controls .workflow-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-create-controls .btn {
|
||||
min-height: 36px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { WorkflowSelector } from "./WorkflowSelector";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const STORAGE_KEY = "kb-inline-create-text";
|
||||
const WORKFLOW_SELECTOR_COLLAPSE_STORAGE_KEY = "kb-board-workflow-selector-collapsed";
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
@@ -1036,6 +1037,8 @@ export function InlineCreateCard({
|
||||
addToast={addToast}
|
||||
label="Workflow"
|
||||
disabled={submitting}
|
||||
collapsible
|
||||
collapseStorageKey={WORKFLOW_SELECTOR_COLLAPSE_STORAGE_KEY}
|
||||
/>
|
||||
|
||||
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
|
||||
|
||||
@@ -36,6 +36,21 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.board-workflow-toolbar[data-collapsed] {
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.board-workflow-collapse-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.board-workflow-collapsed-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.board-workflow-selector {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -160,3 +175,11 @@
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.board-workflow-toolbar[data-collapsed] {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.workflow-selector-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.workflow-selector-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-selector-collapsed-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-selector select {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
@@ -54,7 +71,8 @@
|
||||
}
|
||||
|
||||
.workflow-selector select,
|
||||
.workflow-selector-manage {
|
||||
.workflow-selector-manage,
|
||||
.workflow-selector-collapsed-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import "./WorkflowSelector.css";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Workflow as WorkflowIcon } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Workflow as WorkflowIcon } from "lucide-react";
|
||||
import type { WorkflowDefinition } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { fetchWorkflow, fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
interface WorkflowSelectorProps {
|
||||
/** Currently selected workflow id, or null for none. */
|
||||
@@ -19,6 +20,12 @@ interface WorkflowSelectorProps {
|
||||
label?: string;
|
||||
/** Optional affordance to open the graph editor. */
|
||||
onManage?: () => void;
|
||||
/** Render an opt-in collapsed/expanded shell around the selector. */
|
||||
collapsible?: boolean;
|
||||
/** Base projectStorage key used to persist collapsible state. */
|
||||
collapseStorageKey?: string;
|
||||
/** Label shown when the selector is collapsed. */
|
||||
collapsedLabel?: string;
|
||||
/**
|
||||
* U9: when the task whose workflow is being switched has an active session,
|
||||
* switching aborts that session and re-homes the card into the new workflow's
|
||||
@@ -36,13 +43,40 @@ export function WorkflowSelector({
|
||||
disabled,
|
||||
label = "Workflow",
|
||||
onManage,
|
||||
collapsible = false,
|
||||
collapseStorageKey,
|
||||
collapsedLabel = "Workflow",
|
||||
hasActiveSession,
|
||||
}: WorkflowSelectorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const selectId = useId();
|
||||
const { confirm } = useConfirm();
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (!collapsible || !collapseStorageKey) return false;
|
||||
return getScopedItem(collapseStorageKey, projectId) === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collapsible || !collapseStorageKey) {
|
||||
setCollapsed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsed(getScopedItem(collapseStorageKey, projectId) === "true");
|
||||
}, [collapsible, collapseStorageKey, projectId]);
|
||||
|
||||
const setPersistedCollapsed = useCallback(
|
||||
(nextCollapsed: boolean) => {
|
||||
setCollapsed(nextCollapsed);
|
||||
if (collapsible && collapseStorageKey) {
|
||||
setScopedItem(collapseStorageKey, nextCollapsed ? "true" : "false", projectId);
|
||||
}
|
||||
},
|
||||
[collapsible, collapseStorageKey, projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -100,13 +134,46 @@ export function WorkflowSelector({
|
||||
[onChange, addToast, hasActiveSession, confirm, t],
|
||||
);
|
||||
|
||||
if (collapsible && collapsed) {
|
||||
return (
|
||||
<div className="workflow-selector workflow-selector--collapsed" data-testid="workflow-selector">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm workflow-selector-toggle workflow-selector-collapsed-button"
|
||||
aria-expanded="false"
|
||||
aria-controls={selectId}
|
||||
onClick={() => setPersistedCollapsed(false)}
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
<WorkflowIcon size={14} aria-hidden />
|
||||
{collapsedLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-selector" data-testid="workflow-selector">
|
||||
<label className="workflow-selector-label">
|
||||
<span className="workflow-selector-label-text">
|
||||
<WorkflowIcon size={14} aria-hidden /> {label}
|
||||
</span>
|
||||
<div className="workflow-selector-label">
|
||||
<div className="workflow-selector-label-text">
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm workflow-selector-toggle"
|
||||
aria-expanded="true"
|
||||
aria-controls={selectId}
|
||||
aria-label={t("workflowSelector.collapse", "Collapse workflow selector")}
|
||||
onClick={() => setPersistedCollapsed(true)}
|
||||
>
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<label htmlFor={selectId} className="workflow-selector-title">
|
||||
<WorkflowIcon size={14} aria-hidden /> {label}
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
id={selectId}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || loading || applying}
|
||||
onChange={(e) => void handleChange(e.target.value)}
|
||||
@@ -118,7 +185,7 @@ export function WorkflowSelector({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{onManage && (
|
||||
<button type="button" className="workflow-selector-manage" onClick={onManage}>
|
||||
Manage…
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { Board } from "../Board";
|
||||
import { COLUMNS } from "@fusion/core";
|
||||
@@ -72,6 +72,8 @@ vi.mock("../Lane", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const BOARD_WORKFLOW_COLLAPSED_KEY = "kb-dashboard-board-workflow-collapsed";
|
||||
|
||||
const DEFAULT_WORKFLOW = {
|
||||
id: "builtin:coding",
|
||||
name: "Coding (built-in)",
|
||||
@@ -88,6 +90,14 @@ const DEFAULT_WORKFLOW = {
|
||||
const noop = () => {};
|
||||
const noopAsync = () => Promise.resolve({} as any);
|
||||
|
||||
function clearBoardTestStorage() {
|
||||
try {
|
||||
window.localStorage.clear();
|
||||
} catch {
|
||||
/* jsdom localStorage */
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchBatchMock.mockReset();
|
||||
fetchWorkflowStepsMock.mockReset();
|
||||
@@ -97,16 +107,16 @@ beforeEach(() => {
|
||||
for (const key of Object.keys(sseHandlers)) delete sseHandlers[key];
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
fetchBoardWorkflowsMock.mockImplementation(pendingBoardWorkflows);
|
||||
try {
|
||||
window.localStorage.clear();
|
||||
} catch {
|
||||
/* jsdom localStorage */
|
||||
}
|
||||
clearBoardTestStorage();
|
||||
for (const key of Object.keys(columnRenderCounts)) {
|
||||
delete columnRenderCounts[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearBoardTestStorage();
|
||||
});
|
||||
|
||||
function createBoardProps(overrides = {}) {
|
||||
return {
|
||||
tasks: [],
|
||||
@@ -1036,6 +1046,85 @@ describe("Board", () => {
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("workflow toolbar collapsible", () => {
|
||||
function renderWorkflowToolbarBoard() {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })],
|
||||
onCreateWorkflow,
|
||||
onOpenWorkflowEditor,
|
||||
});
|
||||
return { onCreateWorkflow, onOpenWorkflowEditor };
|
||||
}
|
||||
|
||||
it("defaults to expanded (not collapsed)", async () => {
|
||||
renderWorkflowToolbarBoard();
|
||||
|
||||
expect(await screen.findByLabelText("Select workflow")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
|
||||
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicking the toggle collapses the toolbar", async () => {
|
||||
renderWorkflowToolbarBoard();
|
||||
await screen.findByLabelText("Select workflow");
|
||||
|
||||
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
|
||||
|
||||
expect(screen.queryByLabelText("Select workflow")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "New workflow" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Edit workflows" })).toBeNull();
|
||||
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.getByText("Workflow")).toBeDefined();
|
||||
});
|
||||
|
||||
it("clicking the toggle again expands the toolbar", async () => {
|
||||
renderWorkflowToolbarBoard();
|
||||
await screen.findByLabelText("Select workflow");
|
||||
const toggle = screen.getByTestId("board-workflow-collapse-toggle");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.queryByLabelText("Select workflow")).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.getByLabelText("Select workflow")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("persists collapsed state to localStorage", async () => {
|
||||
renderWorkflowToolbarBoard();
|
||||
await screen.findByLabelText("Select workflow");
|
||||
const toggle = screen.getByTestId("board-workflow-collapse-toggle");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(window.localStorage.getItem(BOARD_WORKFLOW_COLLAPSED_KEY)).toBe("1");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(window.localStorage.getItem(BOARD_WORKFLOW_COLLAPSED_KEY)).toBe("0");
|
||||
});
|
||||
|
||||
it("reads collapsed state from localStorage on mount", async () => {
|
||||
window.localStorage.setItem(BOARD_WORKFLOW_COLLAPSED_KEY, "1");
|
||||
|
||||
renderWorkflowToolbarBoard();
|
||||
await waitFor(() => expect(screen.getByTestId("board-workflow-collapse-toggle")).toBeDefined());
|
||||
|
||||
expect(screen.queryByLabelText("Select workflow")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "New workflow" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Edit workflows" })).toBeNull();
|
||||
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.getByText("Workflow")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks } from "../../api";
|
||||
import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks, fetchWorkflows } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
import type { ModelInfo } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
@@ -20,6 +20,7 @@ vi.mock("lucide-react", () => ({
|
||||
ListTree: () => null,
|
||||
Zap: () => null,
|
||||
ChevronDown: () => null,
|
||||
ChevronRight: () => null,
|
||||
ChevronUp: () => null,
|
||||
Bot: () => null,
|
||||
Server: () => null,
|
||||
@@ -134,6 +135,8 @@ vi.mock("../../api", () => ({
|
||||
|
||||
const TEST_PROJECT_ID = "proj-123";
|
||||
const INLINE_CREATE_STORAGE_KEY = scopedKey("kb-inline-create-text", TEST_PROJECT_ID);
|
||||
const WORKFLOW_SELECTOR_STORAGE_KEY = "kb-board-workflow-selector-collapsed";
|
||||
const SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY = scopedKey(WORKFLOW_SELECTOR_STORAGE_KEY, TEST_PROJECT_ID);
|
||||
|
||||
const MOCK_MODELS: ModelInfo[] = [
|
||||
{
|
||||
@@ -227,6 +230,58 @@ beforeEach(() => {
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
});
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([
|
||||
{ id: "wf-a", name: "Workflow A" },
|
||||
{ id: "wf-b", name: "Workflow B" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("InlineCreateCard workflow selector", () => {
|
||||
it("opts the board inline-create workflow selector into collapsible mode by default", async () => {
|
||||
renderCard();
|
||||
expandCard();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Collapse workflow selector/i })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "Workflow A" })).toBeDefined());
|
||||
});
|
||||
|
||||
it("restores persisted collapsed workflow selector state for the current project", () => {
|
||||
localStorage.setItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY, "true");
|
||||
|
||||
renderCard();
|
||||
expandCard();
|
||||
|
||||
const collapsedButton = screen.getByRole("button", { name: /^Workflow$/i });
|
||||
expect(collapsedButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(collapsedButton).toHaveClass("workflow-selector-collapsed-button");
|
||||
expect(screen.queryByRole("combobox", { name: /Workflow/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("persists collapsed and expanded workflow selector state under the project-scoped key", () => {
|
||||
renderCard();
|
||||
expandCard();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Collapse workflow selector/i }));
|
||||
|
||||
expect(localStorage.getItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY)).toBe("true");
|
||||
expect(screen.getByRole("button", { name: /^Workflow$/i })).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Workflow$/i }));
|
||||
|
||||
expect(localStorage.getItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY)).toBe("false");
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps project workflow selector preferences isolated between projects", () => {
|
||||
localStorage.setItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY, "true");
|
||||
|
||||
renderCard([], { projectId: "other-project" });
|
||||
expandCard();
|
||||
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: /^Workflow$/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard textarea width (FN-1608)", () => {
|
||||
|
||||
@@ -2,8 +2,13 @@ import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { WorkflowSelector } from "../WorkflowSelector";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
vi.mock("lucide-react", () => ({ Workflow: () => null }));
|
||||
vi.mock("lucide-react", () => ({
|
||||
Workflow: () => null,
|
||||
ChevronDown: () => null,
|
||||
ChevronRight: () => null,
|
||||
}));
|
||||
|
||||
const fetchWorkflowsMock = vi.fn();
|
||||
const fetchWorkflowMock = vi.fn();
|
||||
@@ -18,6 +23,7 @@ const mockConfirm = vi.fn();
|
||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }) }));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockConfirm.mockReset();
|
||||
fetchWorkflowsMock.mockReset();
|
||||
fetchWorkflowMock.mockReset();
|
||||
@@ -28,6 +34,71 @@ beforeEach(() => {
|
||||
fetchWorkflowMock.mockResolvedValue({ id: "builtin:hidden", name: "Hidden built-in" });
|
||||
});
|
||||
|
||||
describe("WorkflowSelector collapsible mode", () => {
|
||||
const storageKey = "kb-board-workflow-selector-collapsed";
|
||||
|
||||
it("defaults to expanded when no persisted collapsed state exists", async () => {
|
||||
render(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-a" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Collapse workflow selector/i })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "Workflow A" })).toBeDefined());
|
||||
});
|
||||
|
||||
it("collapses and expands while persisting state by project", async () => {
|
||||
render(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-a" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Collapse workflow selector/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Workflow$/i })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(localStorage.getItem(scopedKey(storageKey, "project-a"))).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Workflow$/i }));
|
||||
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
|
||||
expect(localStorage.getItem(scopedKey(storageKey, "project-a"))).toBe("false");
|
||||
});
|
||||
|
||||
it("restores persisted collapsed state for the current project only", () => {
|
||||
localStorage.setItem(scopedKey(storageKey, "project-a"), "true");
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-a" />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Workflow$/i })).toBeDefined();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
|
||||
rerender(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-b" />);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps the select disabled while loading and when disabled is passed", async () => {
|
||||
fetchWorkflowsMock.mockReturnValue(new Promise(() => undefined));
|
||||
const { rerender } = render(
|
||||
<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDisabled();
|
||||
|
||||
fetchWorkflowsMock.mockResolvedValue([]);
|
||||
rerender(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} disabled />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDisabled());
|
||||
});
|
||||
|
||||
it("renders the None option when the workflow list is empty", async () => {
|
||||
fetchWorkflowsMock.mockResolvedValue([]);
|
||||
|
||||
render(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "None" })).toBeDefined());
|
||||
expect(screen.queryByRole("option", { name: "Workflow A" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowSelector switch-with-active-session confirm (U9)", () => {
|
||||
it("shows the abort-warning confirm and applies the switch when confirmed", async () => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
@@ -84,8 +84,10 @@ describe("projectStorage", () => {
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-dashboard-board-workflow-collapsed",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"kb-board-workflow-selector-collapsed",
|
||||
"fn-agent-view",
|
||||
"kb-terminal-tabs",
|
||||
"kb-planning-last-description",
|
||||
@@ -103,7 +105,7 @@ describe("projectStorage", () => {
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(25);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(27);
|
||||
});
|
||||
|
||||
it("stores branch filter values as scoped strings per project", () => {
|
||||
|
||||
@@ -17,8 +17,10 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-dashboard-board-workflow-collapsed",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"kb-board-workflow-selector-collapsed",
|
||||
"fn-agent-view",
|
||||
"kb-terminal-tabs",
|
||||
"kb-planning-last-description",
|
||||
|
||||
Reference in New Issue
Block a user