feat(FN-2655): inline quick scripts into header terminal split-button
- Replace QuickScriptsDropdown with an inline Header implementation for terminal script actions - Add split-button interaction logic in Header to support default-run and dropdown behavior - Update Header CSS for split-button layout and mobile overflow handling - Expand Header tests to cover split-button behavior and remove obsolete QuickScriptsDropdown tests
This commit is contained in:
@@ -231,10 +231,6 @@
|
||||
.btn-icon:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.btn-icon--terminal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-icon--terminal:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
@@ -285,6 +281,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Split-button for terminal + scripts controls */
|
||||
.terminal-split-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.terminal-split-btn__main {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-split-btn__chevron {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-split-btn__divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Split-button for engine controls */
|
||||
.engine-control-split-btn {
|
||||
position: relative;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
|
||||
import "./Header.css";
|
||||
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
|
||||
@@ -6,7 +6,6 @@ import "./ProjectSelector.css";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
@@ -155,6 +154,12 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
@@ -275,6 +280,11 @@ export function Header({
|
||||
const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false);
|
||||
const [isDesktopOverflowOpen, setIsDesktopOverflowOpen] = useState(false);
|
||||
const [isEngineMenuOpen, setIsEngineMenuOpen] = useState(false);
|
||||
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||
const [highlightedScriptIndex, setHighlightedScriptIndex] = useState(-1);
|
||||
const [scriptsDropdownPosition, setScriptsDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
|
||||
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
|
||||
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -289,6 +299,10 @@ export function Header({
|
||||
const viewOverflowRef = useRef<HTMLDivElement>(null);
|
||||
const viewOverflowTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const engineMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsSplitButtonRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsChevronButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const scriptsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsOpenRef = useRef(false);
|
||||
|
||||
// Get remote nodes only (exclude local node type)
|
||||
const remoteNodes = useMemo(() =>
|
||||
@@ -297,6 +311,14 @@ export function Header({
|
||||
);
|
||||
const showNodeSelector = remoteNodes.length > 0;
|
||||
|
||||
// Script entries sorted alphabetically for desktop scripts dropdown
|
||||
const scriptEntries = useMemo(() => {
|
||||
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [scripts]);
|
||||
|
||||
const showScriptsFooter = scriptEntries.length > 0;
|
||||
const totalScriptItems = scriptEntries.length + (showScriptsFooter ? 1 : 0);
|
||||
|
||||
// Script entries sorted alphabetically for overflow submenu
|
||||
const overflowScriptEntries = useMemo(() => {
|
||||
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
@@ -315,11 +337,139 @@ export function Header({
|
||||
);
|
||||
}, [experimentalFeatures, onChangeView, showSkillsTab]);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (vv && vv.width > 0 && vv.height > 0) {
|
||||
return {
|
||||
width: vv.width,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
offsetLeft: vv.offsetLeft,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateScriptsDropdownPosition = useCallback(() => {
|
||||
const trigger = scriptsChevronButtonRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menu = scriptsMenuRef.current;
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 6;
|
||||
|
||||
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, 260);
|
||||
const width = Math.min(
|
||||
measuredWidth,
|
||||
Math.max(viewportWidth - horizontalPadding * 2, 160),
|
||||
);
|
||||
|
||||
const measuredHeight = menu?.offsetHeight || 280;
|
||||
const constrainedHeight = Math.min(
|
||||
measuredHeight,
|
||||
Math.max(viewportHeight - verticalPadding * 2, 160),
|
||||
);
|
||||
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerRight = rect.right - offsetLeft;
|
||||
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
|
||||
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(triggerRight - width, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - width,
|
||||
) + offsetLeft;
|
||||
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - constrainedHeight,
|
||||
);
|
||||
|
||||
setScriptsDropdownPosition({ top, left, width });
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
const handleRunQuickScript = useCallback(
|
||||
(name: string, command: string) => {
|
||||
onRunScript?.(name, command);
|
||||
setIsScriptsOpen(false);
|
||||
setHighlightedScriptIndex(-1);
|
||||
},
|
||||
[onRunScript],
|
||||
);
|
||||
|
||||
const handleManageScripts = useCallback(() => {
|
||||
onOpenScripts?.();
|
||||
setIsScriptsOpen(false);
|
||||
setHighlightedScriptIndex(-1);
|
||||
}, [onOpenScripts]);
|
||||
|
||||
const handleScriptsDropdownKeyDown = useCallback(
|
||||
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (totalScriptItems > 0) {
|
||||
setHighlightedScriptIndex((prev) => (prev < totalScriptItems - 1 ? prev + 1 : 0));
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (totalScriptItems > 0) {
|
||||
setHighlightedScriptIndex((prev) => (prev > 0 ? prev - 1 : totalScriptItems - 1));
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedScriptIndex >= 0) {
|
||||
if (highlightedScriptIndex < scriptEntries.length) {
|
||||
const [name, command] = scriptEntries[highlightedScriptIndex];
|
||||
handleRunQuickScript(name, command);
|
||||
} else if (showScriptsFooter && highlightedScriptIndex === scriptEntries.length) {
|
||||
handleManageScripts();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
if (totalScriptItems > 0) {
|
||||
setHighlightedScriptIndex(0);
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
if (totalScriptItems > 0) {
|
||||
setHighlightedScriptIndex(totalScriptItems - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleManageScripts, handleRunQuickScript, highlightedScriptIndex, scriptEntries, showScriptsFooter, totalScriptItems],
|
||||
);
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
terminalSubmenuOpenRef.current = isTerminalSubmenuOpen;
|
||||
}, [isTerminalSubmenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
scriptsOpenRef.current = isScriptsOpen;
|
||||
}, [isScriptsOpen]);
|
||||
|
||||
// Fetch scripts when terminal submenu opens in compact mode
|
||||
useEffect(() => {
|
||||
if (!isTerminalSubmenuOpen || !isCompact) return;
|
||||
@@ -349,6 +499,121 @@ export function Header({
|
||||
};
|
||||
}, [isTerminalSubmenuOpen, isCompact, projectId]);
|
||||
|
||||
// Fetch scripts when desktop scripts dropdown opens
|
||||
useEffect(() => {
|
||||
if (!isScriptsOpen || isCompact) return;
|
||||
|
||||
let cancelled = false;
|
||||
setScriptsLoading(true);
|
||||
|
||||
fetchScripts(projectId)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setScripts(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setScripts({});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setScriptsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isScriptsOpen, isCompact, projectId]);
|
||||
|
||||
// Close desktop scripts dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isScriptsOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
scriptsSplitButtonRef.current &&
|
||||
!scriptsSplitButtonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsScriptsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isScriptsOpen]);
|
||||
|
||||
// Close desktop scripts dropdown on Escape
|
||||
useEffect(() => {
|
||||
if (!isScriptsOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsScriptsOpen(false);
|
||||
scriptsChevronButtonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isScriptsOpen]);
|
||||
|
||||
// Reset highlight and focus menu when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isScriptsOpen) {
|
||||
setHighlightedScriptIndex(-1);
|
||||
const timeoutId = window.setTimeout(() => scriptsMenuRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
setScriptsDropdownPosition(null);
|
||||
}, [isScriptsOpen]);
|
||||
|
||||
// Position scripts dropdown when opening and content changes
|
||||
useEffect(() => {
|
||||
if (!isScriptsOpen) return;
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
updateScriptsDropdownPosition();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isScriptsOpen, scriptsLoading, scriptEntries.length, showScriptsFooter, updateScriptsDropdownPosition]);
|
||||
|
||||
// Keep scripts dropdown anchored on viewport changes
|
||||
useEffect(() => {
|
||||
if (!isScriptsOpen) return;
|
||||
|
||||
const handleReposition = () => updateScriptsDropdownPosition();
|
||||
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [isScriptsOpen, updateScriptsDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompact) {
|
||||
setIsScriptsOpen(false);
|
||||
setHighlightedScriptIndex(-1);
|
||||
}
|
||||
}, [isCompact]);
|
||||
|
||||
// Keep mobile search open if there's an active search query
|
||||
const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0;
|
||||
|
||||
@@ -430,6 +695,11 @@ export function Header({
|
||||
setIsTerminalSubmenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (scriptsOpenRef.current) {
|
||||
setIsScriptsOpen(false);
|
||||
scriptsChevronButtonRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
setIsOverflowMenuOpen(false);
|
||||
setIsMobileSearchOpen(false);
|
||||
setIsNodeSelectorOpen(false);
|
||||
@@ -1000,16 +1270,115 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Terminal button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && (
|
||||
<button
|
||||
className="btn-icon btn-icon--terminal"
|
||||
onClick={onToggleTerminal}
|
||||
title="Open Terminal"
|
||||
data-testid="terminal-toggle-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
</button>
|
||||
<div className="terminal-split-btn" ref={scriptsSplitButtonRef}>
|
||||
<button
|
||||
className="btn-icon btn-icon--terminal terminal-split-btn__main"
|
||||
onClick={onToggleTerminal}
|
||||
title="Open Terminal"
|
||||
data-testid="terminal-toggle-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
</button>
|
||||
{onOpenScripts && onRunScript && (
|
||||
<>
|
||||
<span className="terminal-split-btn__divider" />
|
||||
<button
|
||||
ref={scriptsChevronButtonRef}
|
||||
className={`btn-icon terminal-split-btn__chevron${isScriptsOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={() => setIsScriptsOpen((prev) => !prev)}
|
||||
title="Scripts"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isScriptsOpen}
|
||||
aria-label="Quick scripts"
|
||||
data-testid="scripts-btn"
|
||||
>
|
||||
<ChevronDown size={12} className={`quick-scripts-dropdown__trigger-chevron${isScriptsOpen ? " rotate" : ""}`} />
|
||||
</button>
|
||||
{isScriptsOpen && (
|
||||
<div
|
||||
ref={scriptsMenuRef}
|
||||
tabIndex={-1}
|
||||
className="quick-scripts-dropdown__menu"
|
||||
role="listbox"
|
||||
aria-label="Scripts"
|
||||
onKeyDown={handleScriptsDropdownKeyDown}
|
||||
data-testid="quick-scripts-dropdown"
|
||||
style={
|
||||
scriptsDropdownPosition
|
||||
? {
|
||||
position: "fixed",
|
||||
top: `${scriptsDropdownPosition.top}px`,
|
||||
left: `${scriptsDropdownPosition.left}px`,
|
||||
width: `${scriptsDropdownPosition.width}px`,
|
||||
right: "auto",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{scriptsLoading ? (
|
||||
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Loading scripts...</span>
|
||||
</div>
|
||||
) : scriptEntries.length === 0 ? (
|
||||
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
|
||||
<div className="quick-scripts-dropdown__empty-icon">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<p>No scripts configured</p>
|
||||
<button
|
||||
className="quick-scripts-dropdown__empty-action btn"
|
||||
onClick={handleManageScripts}
|
||||
>
|
||||
Add your first script
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="quick-scripts-dropdown__list">
|
||||
{scriptEntries.map(([name, command], index) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`quick-scripts-dropdown__item ${
|
||||
highlightedScriptIndex === index ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={() => handleRunQuickScript(name, command)}
|
||||
role="option"
|
||||
aria-selected={highlightedScriptIndex === index}
|
||||
data-testid={`quick-script-item-${name}`}
|
||||
>
|
||||
<Play size={14} className="quick-scripts-dropdown__item-icon" />
|
||||
<div className="quick-scripts-dropdown__item-info">
|
||||
<span className="quick-scripts-dropdown__item-name">{name}</span>
|
||||
<span className="quick-scripts-dropdown__item-command" title={command}>
|
||||
{command.length > 50 ? `${command.slice(0, 50)}...` : command}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="quick-scripts-dropdown__footer">
|
||||
<button
|
||||
className={`quick-scripts-dropdown__manage ${
|
||||
showScriptsFooter && highlightedScriptIndex === scriptEntries.length ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={handleManageScripts}
|
||||
data-testid="quick-scripts-manage"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>Manage Scripts...</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
@@ -1048,15 +1417,6 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scripts - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenScripts && onRunScript && (
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={onOpenScripts}
|
||||
onRunScript={onRunScript}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop overflow menu for Nodes and Schedules */}
|
||||
{!isCompact && (
|
||||
<div style={{ position: "relative" }}>
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Terminal, Play, Settings, Loader2, ChevronDown } from "lucide-react";
|
||||
import { fetchScripts } from "../api";
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface QuickScriptsDropdownProps {
|
||||
onOpenScripts: () => void;
|
||||
onRunScript: (name: string, command: string) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickScriptsDropdown - Dropdown for quick script execution
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown trigger with Terminal icon + chevron
|
||||
* - Fetches and displays all available scripts
|
||||
* - Click to run script immediately (opens terminal)
|
||||
* - "Manage Scripts..." footer to open full modal
|
||||
* - Keyboard navigation: arrow keys, enter to run, escape to close
|
||||
* - Loading state while fetching
|
||||
* - Empty state when no scripts configured
|
||||
* - Closes on outside click
|
||||
*/
|
||||
export function QuickScriptsDropdown({
|
||||
onOpenScripts,
|
||||
onRunScript,
|
||||
projectId,
|
||||
}: QuickScriptsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (vv && vv.width > 0 && vv.height > 0) {
|
||||
return {
|
||||
width: vv.width,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
offsetLeft: vv.offsetLeft,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menu = menuRef.current;
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 6;
|
||||
|
||||
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, 260);
|
||||
const width = Math.min(
|
||||
measuredWidth,
|
||||
Math.max(viewportWidth - horizontalPadding * 2, 160),
|
||||
);
|
||||
|
||||
const measuredHeight = menu?.offsetHeight || 280;
|
||||
const constrainedHeight = Math.min(
|
||||
measuredHeight,
|
||||
Math.max(viewportHeight - verticalPadding * 2, 160),
|
||||
);
|
||||
|
||||
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 < constrainedHeight && spaceAbove > spaceBelow;
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - width,
|
||||
) + offsetLeft;
|
||||
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - constrainedHeight,
|
||||
);
|
||||
|
||||
setDropdownPosition({ top, left, width });
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
// Script entries sorted alphabetically
|
||||
const scriptEntries = useMemo(() => {
|
||||
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [scripts]);
|
||||
|
||||
const showFooter = scriptEntries.length > 0;
|
||||
|
||||
// Total items for keyboard navigation (scripts + "Manage Scripts...")
|
||||
const totalItems = scriptEntries.length + (showFooter ? 1 : 0);
|
||||
|
||||
// Fetch scripts when dropdown opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
fetchScripts(projectId)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setScripts(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setScripts({});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, projectId]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset highlight when dropdown opens and focus the menu
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHighlightedIndex(-1);
|
||||
// Focus menu for keyboard navigation
|
||||
const timeoutId = window.setTimeout(() => menuRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
setDropdownPosition(null);
|
||||
}, [isOpen]);
|
||||
|
||||
// Position dropdown when opening and whenever content size changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
updateDropdownPosition();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isOpen, loading, scriptEntries.length, showFooter, updateDropdownPosition]);
|
||||
|
||||
// Keep dropdown anchored on viewport and container changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleReposition = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
// Handle keyboard navigation within dropdown
|
||||
const handleDropdownKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < totalItems - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : totalItems - 1
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
if (highlightedIndex < scriptEntries.length) {
|
||||
// Run script
|
||||
const [name, command] = scriptEntries[highlightedIndex];
|
||||
handleRunScript(name, command);
|
||||
} else if (showFooter && highlightedIndex === scriptEntries.length) {
|
||||
// Manage Scripts...
|
||||
handleManageScripts();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(totalItems - 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[highlightedIndex, totalItems, scriptEntries, showFooter]
|
||||
);
|
||||
|
||||
// Toggle dropdown
|
||||
const toggleDropdown = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Handle run script
|
||||
const handleRunScript = useCallback(
|
||||
(name: string, command: string) => {
|
||||
onRunScript(name, command);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[onRunScript]
|
||||
);
|
||||
|
||||
// Handle manage scripts
|
||||
const handleManageScripts = useCallback(() => {
|
||||
onOpenScripts();
|
||||
setIsOpen(false);
|
||||
}, [onOpenScripts]);
|
||||
|
||||
return (
|
||||
<div className="quick-scripts-dropdown" ref={dropdownRef}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className={`btn-icon quick-scripts-dropdown__trigger ${isOpen ? "open btn-icon--active" : ""}`}
|
||||
onClick={toggleDropdown}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Quick scripts"
|
||||
data-testid="scripts-btn"
|
||||
title="Scripts"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`quick-scripts-dropdown__trigger-chevron ${isOpen ? "rotate" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
tabIndex={-1}
|
||||
className="quick-scripts-dropdown__menu"
|
||||
role="listbox"
|
||||
aria-label="Scripts"
|
||||
onKeyDown={handleDropdownKeyDown}
|
||||
data-testid="quick-scripts-dropdown"
|
||||
style={
|
||||
dropdownPosition
|
||||
? {
|
||||
position: "fixed",
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
right: "auto",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Loading scripts...</span>
|
||||
</div>
|
||||
) : scriptEntries.length === 0 ? (
|
||||
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
|
||||
<div className="quick-scripts-dropdown__empty-icon">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<p>No scripts configured</p>
|
||||
<button
|
||||
className="quick-scripts-dropdown__empty-action btn"
|
||||
onClick={handleManageScripts}
|
||||
>
|
||||
Add your first script
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Script list */}
|
||||
<div className="quick-scripts-dropdown__list">
|
||||
{scriptEntries.map(([name, command], index) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`quick-scripts-dropdown__item ${
|
||||
highlightedIndex === index ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={() => handleRunScript(name, command)}
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
data-testid={`quick-script-item-${name}`}
|
||||
>
|
||||
<Play size={14} className="quick-scripts-dropdown__item-icon" />
|
||||
<div className="quick-scripts-dropdown__item-info">
|
||||
<span className="quick-scripts-dropdown__item-name">{name}</span>
|
||||
<span className="quick-scripts-dropdown__item-command" title={command}>
|
||||
{command.length > 50 ? `${command.slice(0, 50)}...` : command}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="quick-scripts-dropdown__footer">
|
||||
<button
|
||||
className={`quick-scripts-dropdown__manage ${
|
||||
showFooter && highlightedIndex === scriptEntries.length ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={handleManageScripts}
|
||||
data-testid="quick-scripts-manage"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>Manage Scripts...</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,10 +177,11 @@ describe("Header", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal button", () => {
|
||||
it("renders terminal button with correct title on desktop", () => {
|
||||
renderHeader({ onToggleTerminal: noop }, "desktop");
|
||||
describe("terminal split button", () => {
|
||||
it("renders terminal main button and scripts chevron on desktop", () => {
|
||||
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
|
||||
expect(screen.getByTitle("Open Terminal")).toBeDefined();
|
||||
expect(screen.getByTestId("scripts-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render terminal button inline on mobile", () => {
|
||||
@@ -188,11 +189,91 @@ describe("Header", () => {
|
||||
expect(screen.queryByTitle("Open Terminal")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal when terminal button is clicked", () => {
|
||||
it("clicking main button calls onToggleTerminal without opening scripts dropdown", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("Open Terminal"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("terminal-toggle-btn"));
|
||||
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking scripts chevron opens dropdown without calling onToggleTerminal", async () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
expect(onToggleTerminal).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches scripts and runs selected script from dropdown", async () => {
|
||||
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
|
||||
const onRunScript = vi.fn();
|
||||
|
||||
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript, projectId: "proj-1" }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-build"));
|
||||
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows loading state while scripts are fetching", () => {
|
||||
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
|
||||
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows empty state when no scripts are configured", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows manage scripts footer when scripts exist", async () => {
|
||||
mockFetchScripts.mockResolvedValue({ test: "pnpm test" });
|
||||
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("supports keyboard navigation in scripts dropdown", async () => {
|
||||
mockFetchScripts.mockResolvedValue({ alpha: "echo alpha", beta: "echo beta" });
|
||||
const onRunScript = vi.fn();
|
||||
|
||||
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
expect(onRunScript).toHaveBeenCalledWith("alpha", "echo alpha");
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
fireEvent.keyDown(screen.getByTestId("quick-scripts-dropdown"), { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("is always enabled regardless of task state", () => {
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickScriptsDropdown } from "../QuickScriptsDropdown";
|
||||
|
||||
// Mock the API functions
|
||||
const mockFetchScripts = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchScripts: () => mockFetchScripts(),
|
||||
}));
|
||||
|
||||
const mockOnOpenScripts = vi.fn();
|
||||
const mockOnRunScript = vi.fn();
|
||||
|
||||
function renderDropdown(props = {}) {
|
||||
return render(
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={mockOnOpenScripts}
|
||||
onRunScript={mockOnRunScript}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QuickScriptsDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the trigger button", () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByTestId("scripts-btn")).toBeDefined();
|
||||
expect(screen.getByTitle("Scripts")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show dropdown menu initially", () => {
|
||||
renderDropdown();
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropdown open/close", () => {
|
||||
it("opens dropdown when trigger is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when clicking outside", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown on Escape key", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when trigger is clicked again", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching and displaying scripts", () => {
|
||||
it("shows loading state while fetching", async () => {
|
||||
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
|
||||
});
|
||||
|
||||
it("fetches and displays scripts", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
test: "npm test",
|
||||
lint: "npm run lint",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
||||
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays script names and truncated commands", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
"long-command": "this is a very long command that should be truncated",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const item = screen.getByTestId("quick-script-item-long-command");
|
||||
expect(item.textContent).toContain("long-command");
|
||||
expect(item.textContent).toContain("this is a very long command that should be truncat...");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles short commands without truncation", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
short: "echo hi",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const item = screen.getByTestId("quick-script-item-short");
|
||||
expect(item.textContent).toContain("short");
|
||||
expect(item.textContent).toContain("echo hi");
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts scripts alphabetically", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
zebra: "echo zebra",
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByRole("option");
|
||||
expect(items[0].textContent).toContain("alpha");
|
||||
expect(items[1].textContent).toContain("beta");
|
||||
expect(items[2].textContent).toContain("zebra");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("running scripts", () => {
|
||||
it("calls onRunScript when a script is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-build"));
|
||||
|
||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("closes dropdown after running script", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
test: "npm test",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-test"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("manage scripts link", () => {
|
||||
it("shows 'Manage Scripts...' link when scripts exist", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows empty state when no scripts configured", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("empty state shows 'Add your first script' button", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking 'Add your first script' calls onOpenScripts", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Add your first script"));
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes dropdown when empty state action is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Add your first script"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("supports ArrowDown to highlight items", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// First ArrowDown highlights first item
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
|
||||
// Second ArrowDown highlights second item
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("supports ArrowUp to highlight items", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Go to bottom first with End key
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
|
||||
// ArrowUp moves to previous item
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("wraps around with arrow keys", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// ArrowUp from start wraps to end (Manage Scripts...)
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
||||
|
||||
// ArrowDown from end wraps to start
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("runs script with Enter key", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Highlight and press Enter
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
|
||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("opens manage scripts with Enter key on manage button", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Navigate to last item (Manage Scripts...) and press Enter
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports Home key to go to first item", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Go to end first
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
// Home goes to first
|
||||
fireEvent.keyDown(menu, { key: "Home" });
|
||||
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("supports End key to go to last item", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
|
||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles fetch errors gracefully", async () => {
|
||||
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show empty state since scripts will be empty object on error
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus management", () => {
|
||||
it("menu is focusable with tabIndex", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
expect(menu).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
|
||||
it("focus moves to trigger when Escape is pressed", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
// Trigger should have focus
|
||||
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user