feat(FN-2042): move Nodes and Schedules to desktop overflow menu

- Add a desktop overflow trigger/menu in Header with outside-click and Escape-key dismissal
- Move Nodes and Automation actions from inline desktop icons into overflow menu items
- Update Header tests to open the desktop overflow and verify visibility and click handlers for both actions
- Add themed desktop overflow menu styles in styles.css using existing spacing, border, radius, and shadow tokens
This commit is contained in:
Fusion
2026-04-17 23:39:40 -07:00
committed by gsxdsm
parent bcc0b8eb01
commit 6386cf22a6
4 changed files with 122 additions and 41 deletions

View File

@@ -647,15 +647,18 @@ describe("Header", () => {
});
describe("nodes button", () => {
it("renders Nodes button on desktop when handler is provided", () => {
it("renders Nodes button in desktop overflow when handler is provided", () => {
renderHeader({ onOpenNodes: vi.fn() }, "desktop");
expect(screen.getByTitle("Nodes")).toBeDefined();
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-nodes-btn")).toBeDefined();
});
it("calls onOpenNodes when Nodes button is clicked", () => {
it("calls onOpenNodes when Nodes button is clicked from desktop overflow", () => {
const onOpenNodes = vi.fn();
renderHeader({ onOpenNodes }, "desktop");
fireEvent.click(screen.getByTitle("Nodes"));
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
fireEvent.click(screen.getByTestId("desktop-overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalled();
});
@@ -800,9 +803,11 @@ describe("Header", () => {
});
describe("automation button", () => {
it("renders automation button on desktop", () => {
it("renders automation button in desktop overflow", () => {
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
expect(screen.getByTitle("Automation")).toBeDefined();
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-schedules-btn")).toBeDefined();
});
it("does not render automation button inline on mobile", () => {
@@ -810,16 +815,18 @@ describe("Header", () => {
expect(screen.queryByTitle("Automation")).toBeNull();
});
it("calls onOpenSchedules when automation button is clicked", () => {
it("calls onOpenSchedules when automation button is clicked from desktop overflow", () => {
const onOpenSchedules = vi.fn();
renderHeader({ onOpenSchedules }, "desktop");
fireEvent.click(screen.getByTitle("Automation"));
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
fireEvent.click(screen.getByTestId("desktop-overflow-schedules-btn"));
expect(onOpenSchedules).toHaveBeenCalled();
});
it("has correct data-testid for testing on desktop", () => {
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
expect(screen.getByTestId("schedules-btn")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-schedules-btn")).toBeDefined();
});
it("includes automation in overflow menu on mobile", () => {

View File

@@ -269,10 +269,13 @@ export function Header({
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false);
const [isDesktopOverflowOpen, setIsDesktopOverflowOpen] = useState(false);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
const overflowMenuRef = useRef<HTMLDivElement>(null);
const desktopOverflowTriggerRef = useRef<HTMLButtonElement>(null);
const desktopOverflowRef = useRef<HTMLDivElement>(null);
const mobileSearchRef = useRef<HTMLDivElement>(null);
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const terminalSubmenuOpenRef = useRef(false);
@@ -362,6 +365,25 @@ export function Header({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOverflowMenuOpen]);
// Close desktop overflow menu on outside click
useEffect(() => {
if (!isDesktopOverflowOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
desktopOverflowRef.current &&
!desktopOverflowRef.current.contains(e.target as Node) &&
desktopOverflowTriggerRef.current &&
!desktopOverflowTriggerRef.current.contains(e.target as Node)
) {
setIsDesktopOverflowOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isDesktopOverflowOpen]);
// Close node selector on outside click
useEffect(() => {
if (!isNodeSelectorOpen) return;
@@ -384,6 +406,7 @@ export function Header({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsViewOverflowOpen(false);
setIsDesktopOverflowOpen(false);
if (terminalSubmenuOpenRef.current) {
setIsTerminalSubmenuOpen(false);
return;
@@ -897,18 +920,6 @@ export function Header({
</button>
)}
{/* Schedules button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
<button
className="btn-icon"
onClick={onOpenSchedules}
title="Automation"
data-testid="schedules-btn"
>
<Clock size={16} />
</button>
)}
{/* Terminal button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
<button
@@ -945,18 +956,6 @@ export function Header({
</button>
)}
{/* Nodes button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenNodes && showNodesButton !== false && (
<button
className="btn-icon"
onClick={onOpenNodes}
title="Nodes"
data-testid="nodes-btn"
>
<Server size={16} />
</button>
)}
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenWorkflowSteps && (
<button
@@ -978,6 +977,59 @@ export function Header({
/>
)}
{/* Desktop overflow menu for Nodes and Schedules */}
{!isCompact && (
<div style={{ position: "relative" }}>
<button
ref={desktopOverflowTriggerRef}
className="btn-icon"
onClick={() => setIsDesktopOverflowOpen((prev) => !prev)}
title="More actions"
aria-label="More actions"
aria-expanded={isDesktopOverflowOpen}
aria-haspopup="menu"
data-testid="desktop-overflow-trigger"
>
<MoreHorizontal size={16} />
</button>
{isDesktopOverflowOpen && (
<div
ref={desktopOverflowRef}
className="desktop-overflow-menu"
role="menu"
aria-label="More actions"
>
{onOpenNodes && showNodesButton !== false && (
<button
className="view-toggle-overflow-item"
onClick={() => {
onOpenNodes();
setIsDesktopOverflowOpen(false);
}}
role="menuitem"
data-testid="desktop-overflow-nodes-btn"
>
<Server size={14} />
<span>Nodes</span>
</button>
)}
<button
className="view-toggle-overflow-item"
onClick={() => {
handleOverflowAction(onOpenSchedules);
setIsDesktopOverflowOpen(false);
}}
role="menuitem"
data-testid="desktop-overflow-schedules-btn"
>
<Clock size={14} />
<span>Automation</span>
</button>
</div>
)}
</div>
)}
{/* Pause button (soft pause) - always inline */}
<button
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}

View File

@@ -1632,25 +1632,32 @@ describe("Header", () => {
describe("Nodes button visibility", () => {
describe("desktop viewport", () => {
it("shows nodes button by default when onOpenNodes is provided without showNodesButton", () => {
it("shows nodes button in desktop overflow by default when onOpenNodes is provided without showNodesButton", () => {
render(<Header onOpenSettings={vi.fn()} onOpenNodes={vi.fn()} />);
expect(screen.getByTestId("nodes-btn")).toBeDefined();
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-nodes-btn")).toBeDefined();
});
it("shows nodes button when showNodesButton is true and onOpenNodes is provided", () => {
it("shows nodes button in desktop overflow when showNodesButton is true and onOpenNodes is provided", () => {
render(<Header onOpenSettings={vi.fn()} onOpenNodes={vi.fn()} showNodesButton={true} />);
expect(screen.getByTestId("nodes-btn")).toBeDefined();
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.getByTestId("desktop-overflow-nodes-btn")).toBeDefined();
});
it("hides nodes button when showNodesButton is false", () => {
it("hides nodes button from desktop overflow when showNodesButton is false", () => {
render(<Header onOpenSettings={vi.fn()} onOpenNodes={vi.fn()} showNodesButton={false} />);
expect(screen.queryByTestId("nodes-btn")).toBeNull();
expect(screen.getByTestId("desktop-overflow-trigger")).toBeDefined();
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
expect(screen.queryByTestId("desktop-overflow-nodes-btn")).toBeNull();
});
it("calls onOpenNodes when nodes button is clicked", () => {
it("calls onOpenNodes when nodes button is clicked from desktop overflow", () => {
const onOpenNodes = vi.fn();
render(<Header onOpenSettings={vi.fn()} onOpenNodes={onOpenNodes} />);
fireEvent.click(screen.getByTestId("nodes-btn"));
fireEvent.click(screen.getByTestId("desktop-overflow-trigger"));
fireEvent.click(screen.getByTestId("desktop-overflow-nodes-btn"));
expect(onOpenNodes).toHaveBeenCalledOnce();
});
});

View File

@@ -930,6 +930,21 @@ body {
color: var(--todo);
}
/* Desktop Overflow Menu */
.desktop-overflow-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--space-xs);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 140px;
z-index: 200;
padding: var(--space-xs) 0;
}
/* Header Search */
.header-search {
display: flex;