feat(FN-1660): merge fusion/fn-1660
This commit is contained in:
5
.changeset/fn-1660-mobile-project-switcher.md
Normal file
5
.changeset/fn-1660-mobile-project-switcher.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Add mobile-first project switch affordance in the dashboard header. Users with 2+ projects on mobile viewports now see a project dropdown icon next to the Fusion logo, enabling direct project switching without opening the overflow menu. The dropdown supports direct project selection, shows current project highlighting, and closes on outside click or Escape. Desktop and tablet behavior remains unchanged.
|
||||
@@ -399,6 +399,19 @@ describe("tablet header controls", () => {
|
||||
expect(btn.textContent).toContain("Projects");
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
renderTabletHeader({
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
});
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Desktop still shows everything inline ──────────────────────
|
||||
|
||||
describe("desktop regression (contrasted with tablet)", () => {
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -19231,6 +19231,126 @@ html .column.drag-over * {
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
/* === Mobile Project Switch (logo-adjacent dropdown) === */
|
||||
.mobile-project-switch {
|
||||
position: relative;
|
||||
display: none; /* Hidden by default, shown on mobile via media query */
|
||||
}
|
||||
|
||||
.mobile-project-switch-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-project-switch-trigger:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-project-switch-trigger--open {
|
||||
color: var(--todo);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.mobile-project-switch-chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-project-switch-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mobile-project-switch-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
padding: var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.mobile-project-switch-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-project-switch-item:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.mobile-project-switch-item--current {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.mobile-project-switch-dot {
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mobile-project-switch-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mobile-project-switch-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-project-switch-path {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-project-switch-check {
|
||||
flex-shrink: 0;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
/* Mobile styles for mobile-project-switch */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-project-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Project Content Wrapper (footer-safe layout) === */
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user