feat(FN-1660): merge fusion/fn-1660
This commit is contained in:
@@ -925,6 +925,57 @@ describe("Header", () => {
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onSearchChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("does not render mobile project switch trigger on desktop", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
];
|
||||
renderHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "desktop");
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render mobile project switch trigger on tablet", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
];
|
||||
renderHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "tablet");
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders mobile project switch trigger on mobile with 2+ projects", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
];
|
||||
renderHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "mobile");
|
||||
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render mobile project switch trigger on mobile with single project", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
];
|
||||
renderHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "mobile");
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Back to All Projects button", () => {
|
||||
|
||||
@@ -254,6 +254,7 @@ export function Header({
|
||||
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
|
||||
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
|
||||
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
|
||||
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
|
||||
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -262,6 +263,7 @@ export function Header({
|
||||
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const terminalSubmenuOpenRef = useRef(false);
|
||||
const nodeSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get remote nodes only (exclude local node type)
|
||||
const remoteNodes = useMemo(() =>
|
||||
@@ -372,6 +374,7 @@ export function Header({
|
||||
setIsOverflowMenuOpen(false);
|
||||
setIsMobileSearchOpen(false);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsMobileProjectSwitchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,6 +382,23 @@ export function Header({
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Close mobile project switch on outside click
|
||||
useEffect(() => {
|
||||
if (!isMobileProjectSwitchOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
mobileProjectSwitchRef.current &&
|
||||
!mobileProjectSwitchRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsMobileProjectSwitchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isMobileProjectSwitchOpen]);
|
||||
|
||||
const handleMobileSearchToggle = useCallback(() => {
|
||||
setIsMobileSearchOpen((prev) => !prev);
|
||||
}, []);
|
||||
@@ -438,6 +458,61 @@ export function Header({
|
||||
<h1 className="logo">Fusion</h1>
|
||||
</div>
|
||||
|
||||
{/* Mobile Project Switch - dropdown trigger next to logo when 2+ projects (mobile only) */}
|
||||
{isMobile && projects.length > 1 && onSelectProject && (
|
||||
<div className="mobile-project-switch" ref={mobileProjectSwitchRef}>
|
||||
<button
|
||||
className={`mobile-project-switch-trigger${isMobileProjectSwitchOpen ? " mobile-project-switch-trigger--open" : ""}`}
|
||||
onClick={() => setIsMobileProjectSwitchOpen((prev) => !prev)}
|
||||
title="Switch project"
|
||||
aria-label="Switch project"
|
||||
aria-expanded={isMobileProjectSwitchOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="mobile-project-switch-trigger"
|
||||
>
|
||||
<ChevronDown size={14} className={`mobile-project-switch-chevron${isMobileProjectSwitchOpen ? " mobile-project-switch-chevron--open" : ""}`} />
|
||||
</button>
|
||||
{isMobileProjectSwitchOpen && (
|
||||
<div
|
||||
className="mobile-project-switch-dropdown"
|
||||
role="listbox"
|
||||
aria-label="Select project"
|
||||
data-testid="mobile-project-switch-dropdown"
|
||||
>
|
||||
{projects.map((project) => {
|
||||
const isCurrent = currentProject?.id === project.id;
|
||||
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`mobile-project-switch-item${isCurrent ? " mobile-project-switch-item--current" : ""}`}
|
||||
onClick={() => {
|
||||
onSelectProject(project);
|
||||
setIsMobileProjectSwitchOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
data-testid={`mobile-project-switch-item-${project.id}`}
|
||||
>
|
||||
<span
|
||||
className="mobile-project-switch-dot"
|
||||
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
|
||||
/>
|
||||
<div className="mobile-project-switch-info">
|
||||
<span className="mobile-project-switch-name">{project.name}</span>
|
||||
<span className="mobile-project-switch-path">
|
||||
{project.path.split("/").slice(-2).join("/")}
|
||||
</span>
|
||||
</div>
|
||||
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (desktop only) */}
|
||||
{!isCompact && projects.length >= 1 && onViewAllProjects && (
|
||||
<ProjectSelector
|
||||
|
||||
@@ -998,6 +998,176 @@ describe("Header", () => {
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onSearchChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
// ── Mobile Project Switch (logo-adjacent) ─────────────────────
|
||||
|
||||
it("renders mobile project switch trigger when 2+ projects and onSelectProject provided", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render mobile project switch trigger with single project", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render mobile project switch trigger when onSelectProject is not provided", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("mobile project switch trigger opens dropdown when clicked", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const trigger = screen.getByTestId("mobile-project-switch-trigger");
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mobile project switch dropdown shows all projects", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
expect(screen.getByTestId("mobile-project-switch-item-proj_1")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-project-switch-item-proj_2")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mobile project switch calls onSelectProject when project is selected", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onSelectProject = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={onSelectProject}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-item-proj_2"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
|
||||
});
|
||||
|
||||
it("mobile project switch dropdown closes after selection", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-item-proj_2"));
|
||||
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("mobile project switch closes on outside click", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("mobile project switch closes on Escape key", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("mobile project switch shows current project as selected", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
const currentItem = screen.getByTestId("mobile-project-switch-item-proj_1");
|
||||
expect(currentItem.getAttribute("aria-selected")).toBe("true");
|
||||
const otherItem = screen.getByTestId("mobile-project-switch-item-proj_2");
|
||||
expect(otherItem.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Project Selector ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user