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:
@@ -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", () => {
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user