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