FN-6080: remove workflow selector collapse controls
Always render the workflow selector expanded across the dashboard. - remove collapsible workflow selector props, state, icons, and toggle styles - stop InlineCreateCard from passing collapse persistence options - delete project storage support and tests for collapsed workflow selector state - add a patch changeset for the published CLI package Files changed: .changeset/fn-6080-removal.md | 5 ++ .../dashboard/app/components/InlineCreateCard.tsx | 3 - .../dashboard/app/components/WorkflowSelector.css | 14 +---- .../dashboard/app/components/WorkflowSelector.tsx | 65 +-------------------- .../components/__tests__/InlineCreateCard.test.tsx | 50 ---------------- .../components/__tests__/WorkflowSelector.test.tsx | 68 ---------------------- .../app/utils/__tests__/projectStorage.test.ts | 3 +- packages/dashboard/app/utils/projectStorage.ts | 1 - 8 files changed, 8 insertions(+), 201 deletions(-) Fusion-Task-Id: FN-6080 Fusion-Task-Lineage: cca26111-9e20-47d0-8473-d18a3b488ae1
This commit is contained in:
5
.changeset/fn-6080-removal.md
Normal file
5
.changeset/fn-6080-removal.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Removed the `collapsible`, `collapseStorageKey`, and `collapsedLabel` props from `WorkflowSelector`. Callers should stop passing these props; workflow selectors now always render expanded.
|
||||||
@@ -18,7 +18,6 @@ import { WorkflowSelector } from "./WorkflowSelector";
|
|||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
const STORAGE_KEY = "kb-inline-create-text";
|
const STORAGE_KEY = "kb-inline-create-text";
|
||||||
const WORKFLOW_SELECTOR_COLLAPSE_STORAGE_KEY = "kb-board-workflow-selector-collapsed";
|
|
||||||
|
|
||||||
interface PendingImage {
|
interface PendingImage {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -1037,8 +1036,6 @@ export function InlineCreateCard({
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
label="Workflow"
|
label="Workflow"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
collapsible
|
|
||||||
collapseStorageKey={WORKFLOW_SELECTOR_COLLAPSE_STORAGE_KEY}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
|
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
|
||||||
|
|||||||
@@ -26,17 +26,6 @@
|
|||||||
gap: var(--space-xs);
|
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 {
|
.workflow-selector select {
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -71,8 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-selector select,
|
.workflow-selector select,
|
||||||
.workflow-selector-manage,
|
.workflow-selector-manage {
|
||||||
.workflow-selector-collapsed-button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import "./WorkflowSelector.css";
|
import "./WorkflowSelector.css";
|
||||||
import { useCallback, useEffect, useId, useState } from "react";
|
import { useCallback, useEffect, useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronRight, Workflow as WorkflowIcon } from "lucide-react";
|
import { Workflow as WorkflowIcon } from "lucide-react";
|
||||||
import type { WorkflowDefinition } from "@fusion/core";
|
import type { WorkflowDefinition } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import { fetchWorkflow, fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
|
import { fetchWorkflow, fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
|
||||||
|
|
||||||
interface WorkflowSelectorProps {
|
interface WorkflowSelectorProps {
|
||||||
/** Currently selected workflow id, or null for none. */
|
/** Currently selected workflow id, or null for none. */
|
||||||
@@ -20,12 +19,6 @@ interface WorkflowSelectorProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** Optional affordance to open the graph editor. */
|
/** Optional affordance to open the graph editor. */
|
||||||
onManage?: () => void;
|
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,
|
* 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
|
* switching aborts that session and re-homes the card into the new workflow's
|
||||||
@@ -43,9 +36,6 @@ export function WorkflowSelector({
|
|||||||
disabled,
|
disabled,
|
||||||
label = "Workflow",
|
label = "Workflow",
|
||||||
onManage,
|
onManage,
|
||||||
collapsible = false,
|
|
||||||
collapseStorageKey,
|
|
||||||
collapsedLabel = "Workflow",
|
|
||||||
hasActiveSession,
|
hasActiveSession,
|
||||||
}: WorkflowSelectorProps) {
|
}: WorkflowSelectorProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
@@ -54,29 +44,6 @@ export function WorkflowSelector({
|
|||||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [applying, setApplying] = 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -134,40 +101,10 @@ export function WorkflowSelector({
|
|||||||
[onChange, addToast, hasActiveSession, confirm, t],
|
[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 (
|
return (
|
||||||
<div className="workflow-selector" data-testid="workflow-selector">
|
<div className="workflow-selector" data-testid="workflow-selector">
|
||||||
<div className="workflow-selector-label">
|
<div className="workflow-selector-label">
|
||||||
<div className="workflow-selector-label-text">
|
<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">
|
<label htmlFor={selectId} className="workflow-selector-title">
|
||||||
<WorkflowIcon size={14} aria-hidden /> {label}
|
<WorkflowIcon size={14} aria-hidden /> {label}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -135,8 +135,6 @@ vi.mock("../../api", () => ({
|
|||||||
|
|
||||||
const TEST_PROJECT_ID = "proj-123";
|
const TEST_PROJECT_ID = "proj-123";
|
||||||
const INLINE_CREATE_STORAGE_KEY = scopedKey("kb-inline-create-text", TEST_PROJECT_ID);
|
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[] = [
|
const MOCK_MODELS: ModelInfo[] = [
|
||||||
{
|
{
|
||||||
@@ -236,54 +234,6 @@ beforeEach(() => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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)", () => {
|
describe("InlineCreateCard textarea width (FN-1608)", () => {
|
||||||
it("textarea spans full container width in board view", () => {
|
it("textarea spans full container width in board view", () => {
|
||||||
renderCard();
|
renderCard();
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import React from "react";
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { WorkflowSelector } from "../WorkflowSelector";
|
import { WorkflowSelector } from "../WorkflowSelector";
|
||||||
import { scopedKey } from "../../utils/projectStorage";
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Workflow: () => null,
|
Workflow: () => null,
|
||||||
ChevronDown: () => null,
|
|
||||||
ChevronRight: () => null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fetchWorkflowsMock = vi.fn();
|
const fetchWorkflowsMock = vi.fn();
|
||||||
@@ -34,71 +31,6 @@ beforeEach(() => {
|
|||||||
fetchWorkflowMock.mockResolvedValue({ id: "builtin:hidden", name: "Hidden built-in" });
|
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)", () => {
|
describe("WorkflowSelector switch-with-active-session confirm (U9)", () => {
|
||||||
it("shows the abort-warning confirm and applies the switch when confirmed", async () => {
|
it("shows the abort-warning confirm and applies the switch when confirmed", async () => {
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ describe("projectStorage", () => {
|
|||||||
"kb-dashboard-board-workflow-collapsed",
|
"kb-dashboard-board-workflow-collapsed",
|
||||||
"kb-quick-entry-text",
|
"kb-quick-entry-text",
|
||||||
"kb-inline-create-text",
|
"kb-inline-create-text",
|
||||||
"kb-board-workflow-selector-collapsed",
|
|
||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
"kb-terminal-tabs",
|
"kb-terminal-tabs",
|
||||||
"kb-planning-last-description",
|
"kb-planning-last-description",
|
||||||
@@ -105,7 +104,7 @@ describe("projectStorage", () => {
|
|||||||
"fusion-plugin-dependency-graph:positions",
|
"fusion-plugin-dependency-graph:positions",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(27);
|
expect(PROJECT_STORAGE_KEYS).toHaveLength(26);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores branch filter values as scoped strings per project", () => {
|
it("stores branch filter values as scoped strings per project", () => {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
|||||||
"kb-dashboard-board-workflow-collapsed",
|
"kb-dashboard-board-workflow-collapsed",
|
||||||
"kb-quick-entry-text",
|
"kb-quick-entry-text",
|
||||||
"kb-inline-create-text",
|
"kb-inline-create-text",
|
||||||
"kb-board-workflow-selector-collapsed",
|
|
||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
"kb-terminal-tabs",
|
"kb-terminal-tabs",
|
||||||
"kb-planning-last-description",
|
"kb-planning-last-description",
|
||||||
|
|||||||
Reference in New Issue
Block a user