feat(FN-1305): add scripts submenu to MobileNavBar

- Add expandable scripts submenu inside the More sheet on MobileNavBar
- Include script status indicators, run/stop buttons, and navigation
- Add CSS styles for submenu animation, hover states, and mobile touch targets
- Add comprehensive tests for submenu toggle, script actions, and edge cases
This commit is contained in:
gsxdsm
2026-04-09 05:22:17 -07:00
parent e170073dcf
commit ed632615cc
3 changed files with 302 additions and 10 deletions

View File

@@ -1,21 +1,26 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Activity,
Bot,
ChevronRight,
Clock,
FileCode,
Folder,
GitBranch,
Grid3X3,
LayoutGrid,
List,
Lightbulb,
Loader2,
Mail,
MoreHorizontal,
Play,
Settings,
Target,
Terminal,
Workflow,
} from "lucide-react";
import { fetchScripts } from "../api";
import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
@@ -78,6 +83,7 @@ export function MobileNavBar({
onOpenGitManager,
onOpenWorkflowSteps,
onOpenSchedules,
onOpenScripts,
onToggleTerminal,
onOpenFiles,
onOpenGitHubImport,
@@ -85,10 +91,43 @@ export function MobileNavBar({
onResumePlanning,
activePlanningSessionCount = 0,
onOpenUsage,
onRunScript,
projectId,
onViewAllProjects,
}: MobileNavBarProps) {
const mode = useViewportMode();
const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const scriptEntries = useMemo(
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
[scripts],
);
// Fetch scripts when the submenu opens
useEffect(() => {
if (!isScriptsSubmenuOpen) 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;
};
}, [isScriptsSubmenuOpen, projectId]);
const closeMore = useCallback(() => setIsMoreOpen(false), []);
@@ -229,15 +268,91 @@ export function MobileNavBar({
<span>Git Manager</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-terminal"
onClick={() => handleMoreAction(onToggleTerminal)}
>
<Terminal />
<span>Terminal</span>
</button>
<div className="mobile-more-split-row">
<button
type="button"
className="mobile-more-item mobile-more-split-primary"
data-testid="mobile-more-item-terminal"
onClick={() => handleMoreAction(onToggleTerminal)}
>
<Terminal />
<span>Terminal</span>
</button>
<button
type="button"
className="mobile-more-split-toggle"
data-testid="mobile-more-terminal-split-toggle"
onClick={() => setIsScriptsSubmenuOpen((prev) => !prev)}
aria-expanded={isScriptsSubmenuOpen}
aria-haspopup="menu"
aria-label="Show scripts"
>
<ChevronRight
size={14}
className={`mobile-more-chevron${isScriptsSubmenuOpen ? " mobile-more-chevron--open" : ""}`}
/>
</button>
</div>
{isScriptsSubmenuOpen && (
<div className="mobile-more-submenu" role="menu" aria-label="Scripts submenu">
{scriptsLoading ? (
<div className="mobile-more-submenu-loading" data-testid="mobile-more-scripts-loading">
<Loader2 size={14} className="animate-spin" />
<span>Loading scripts</span>
</div>
) : scriptEntries.length > 0 ? (
<>
{scriptEntries.map(([name, command]) => (
<button
key={name}
type="button"
className="mobile-more-item mobile-more-subitem"
data-testid={`mobile-more-script-item-${name}`}
onClick={() => {
if (onRunScript) onRunScript(name, command);
closeMore();
setIsScriptsSubmenuOpen(false);
}}
>
<Play size={14} />
<span>{name}</span>
</button>
))}
{onOpenScripts && (
<button
type="button"
className="mobile-more-item mobile-more-subitem mobile-more-subitem--manage"
data-testid="mobile-more-scripts-manage"
onClick={() => {
closeMore();
setIsScriptsSubmenuOpen(false);
onOpenScripts();
}}
>
<FileCode size={14} />
<span>Manage Scripts</span>
</button>
)}
</>
) : (
onOpenScripts && (
<button
type="button"
className="mobile-more-item mobile-more-subitem"
data-testid="mobile-more-scripts-manage"
onClick={() => {
closeMore();
setIsScriptsSubmenuOpen(false);
onOpenScripts();
}}
>
<FileCode size={14} />
<span>No scripts add one</span>
</button>
)
)}
</div>
)}
<button
type="button"

View File

@@ -2,6 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MobileNavBar } from "../MobileNavBar";
vi.mock("../../api", () => ({
fetchScripts: vi.fn(),
}));
import { fetchScripts } from "../../api";
function mockViewport(mode: "mobile" | "desktop") {
Object.defineProperty(window, "matchMedia", {
writable: true,
@@ -224,4 +230,111 @@ describe("MobileNavBar", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
});
describe("scripts submenu", () => {
beforeEach(() => {
vi.mocked(fetchScripts).mockReset();
});
it("terminal item has a split toggle that opens scripts submenu", async () => {
vi.mocked(fetchScripts).mockResolvedValue({});
render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
const toggle = screen.getByTestId("mobile-more-terminal-split-toggle");
expect(toggle).toBeDefined();
fireEvent.click(toggle);
await waitFor(() => {
expect(screen.getByTestId("mobile-more-scripts-manage")).toBeDefined();
});
});
it("scripts are fetched when submenu opens", async () => {
vi.mocked(fetchScripts).mockResolvedValue({
build: "pnpm build",
test: "pnpm test",
});
render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
await waitFor(() => {
expect(screen.getByTestId("mobile-more-script-item-build")).toBeDefined();
expect(screen.getByTestId("mobile-more-script-item-test")).toBeDefined();
});
});
it("clicking a script item calls onRunScript and closes sheet", async () => {
vi.mocked(fetchScripts).mockResolvedValue({
build: "pnpm build",
});
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
await waitFor(() => {
expect(screen.getByTestId("mobile-more-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mobile-more-script-item-build"));
expect(props.onRunScript).toHaveBeenCalledWith("build", "pnpm build");
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("manage scripts button calls onOpenScripts and closes sheet", async () => {
vi.mocked(fetchScripts).mockResolvedValue({
build: "pnpm build",
});
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
await waitFor(() => {
expect(screen.getByTestId("mobile-more-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mobile-more-scripts-manage"));
expect(props.onOpenScripts).toHaveBeenCalledOnce();
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("empty scripts state shows 'No scripts' item", async () => {
vi.mocked(fetchScripts).mockResolvedValue({});
render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
await waitFor(() => {
const manageBtn = screen.getByTestId("mobile-more-scripts-manage");
expect(manageBtn).toBeDefined();
expect(manageBtn.textContent).toContain("No scripts — add one…");
});
});
it("loading state shows spinner while fetching", async () => {
let resolveFetch!: (value: Record<string, string>) => void;
vi.mocked(fetchScripts).mockImplementation(
() => new Promise((resolve) => { resolveFetch = resolve; }),
);
render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
expect(screen.getByTestId("mobile-more-scripts-loading")).toBeDefined();
// Resolve to clean up
resolveFetch({});
await waitFor(() => {
expect(screen.queryByTestId("mobile-more-scripts-loading")).toBeNull();
});
});
});
});

View File

@@ -26147,6 +26147,70 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
margin: 8px 16px;
}
.mobile-more-split-row {
display: flex;
flex-direction: row;
align-items: stretch;
}
.mobile-more-split-primary {
flex: 1;
min-width: 0;
}
.mobile-more-split-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast);
}
.mobile-more-split-toggle:hover {
background: var(--card);
}
.mobile-more-chevron {
color: var(--text-muted);
transition: transform var(--transition-fast);
}
.mobile-more-chevron--open {
transform: rotate(90deg);
}
.mobile-more-submenu {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: var(--space-md);
}
.mobile-more-subitem {
padding-left: 28px;
}
.mobile-more-submenu-loading {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 8px 12px 8px 28px;
color: var(--text-muted);
font-size: 12px;
}
.mobile-more-subitem--manage {
color: var(--text-muted);
border-top: 1px solid var(--border);
margin-top: 2px;
padding-top: 8px;
}
/* FN-1137: Quick scripts dropdown mobile viewport handling */
@media (max-width: 768px) {
.quick-scripts-dropdown__menu {