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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
FileCode,
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
Settings,
|
Settings,
|
||||||
Target,
|
Target,
|
||||||
Terminal,
|
Terminal,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { fetchScripts } from "../api";
|
||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
|
|
||||||
export interface MobileNavBarProps {
|
export interface MobileNavBarProps {
|
||||||
@@ -78,6 +83,7 @@ export function MobileNavBar({
|
|||||||
onOpenGitManager,
|
onOpenGitManager,
|
||||||
onOpenWorkflowSteps,
|
onOpenWorkflowSteps,
|
||||||
onOpenSchedules,
|
onOpenSchedules,
|
||||||
|
onOpenScripts,
|
||||||
onToggleTerminal,
|
onToggleTerminal,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
onOpenGitHubImport,
|
onOpenGitHubImport,
|
||||||
@@ -85,10 +91,43 @@ export function MobileNavBar({
|
|||||||
onResumePlanning,
|
onResumePlanning,
|
||||||
activePlanningSessionCount = 0,
|
activePlanningSessionCount = 0,
|
||||||
onOpenUsage,
|
onOpenUsage,
|
||||||
|
onRunScript,
|
||||||
|
projectId,
|
||||||
onViewAllProjects,
|
onViewAllProjects,
|
||||||
}: MobileNavBarProps) {
|
}: MobileNavBarProps) {
|
||||||
const mode = useViewportMode();
|
const mode = useViewportMode();
|
||||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
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), []);
|
const closeMore = useCallback(() => setIsMoreOpen(false), []);
|
||||||
|
|
||||||
@@ -229,15 +268,91 @@ export function MobileNavBar({
|
|||||||
<span>Git Manager</span>
|
<span>Git Manager</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<div className="mobile-more-split-row">
|
||||||
type="button"
|
<button
|
||||||
className="mobile-more-item"
|
type="button"
|
||||||
data-testid="mobile-more-item-terminal"
|
className="mobile-more-item mobile-more-split-primary"
|
||||||
onClick={() => handleMoreAction(onToggleTerminal)}
|
data-testid="mobile-more-item-terminal"
|
||||||
>
|
onClick={() => handleMoreAction(onToggleTerminal)}
|
||||||
<Terminal />
|
>
|
||||||
<span>Terminal</span>
|
<Terminal />
|
||||||
</button>
|
<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
|
<button
|
||||||
type="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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MobileNavBar } from "../MobileNavBar";
|
import { MobileNavBar } from "../MobileNavBar";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchScripts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetchScripts } from "../../api";
|
||||||
|
|
||||||
function mockViewport(mode: "mobile" | "desktop") {
|
function mockViewport(mode: "mobile" | "desktop") {
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -224,4 +230,111 @@ describe("MobileNavBar", () => {
|
|||||||
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
|
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
|
||||||
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
|
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;
|
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 */
|
/* FN-1137: Quick scripts dropdown mobile viewport handling */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.quick-scripts-dropdown__menu {
|
.quick-scripts-dropdown__menu {
|
||||||
|
|||||||
Reference in New Issue
Block a user