FN-8595: add mobile project favorite controls

Group mobile project switcher favorites ahead of other projects.

- Reuse the shared bookmark store for mobile project rows and toggles.
- Add responsive favorite-control styling and coverage for grouping, empty states, and selection behavior.
- Add a release changeset for the mobile favorite-project experience.

Files changed:
 .changeset/mobile-project-favorites.md             |   7 ++
 packages/dashboard/app/components/Header.tsx       | 120 +++++++++++++++------
 .../dashboard/app/components/ProjectSelector.css   |  40 +++++++
 .../Header.mobile-project-favorites.test.tsx       | 116 ++++++++++++++++++++
 4 files changed, 253 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-8595

Fusion-Task-Lineage: 1f739ee9-1ff5-4b47-ac9e-7e4a5857f1a5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-25 21:55:54 -07:00
parent f1a202916f
commit 147398f2c8
4 changed files with 253 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mobile project drop-down now lists favorite projects in a separate section at the top.
category: feature
dev: Header mobile switcher reuses `useProjectBookmarks` (localStorage `fusion_project_bookmarks`).

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, Lightbulb, ChevronDown, ChevronRight, PanelRight } from "lucide-react";
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, Lightbulb, ChevronDown, ChevronRight, PanelRight, Star } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector";
import { useProjectBookmarks } from "../hooks/useProjectBookmarks";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { NodeStatusIndicator } from "./NodeStatusIndicator";
@@ -211,6 +212,73 @@ export function Header({
[availableNodes]
);
const showNodeSelector = remoteNodes.length > 0;
const { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks();
/*
FNXC:ProjectSelector 2026-08-26-00:00:
Mobile project switching must separate favorites at the top while sharing the desktop localStorage bookmark store. Preserve the incoming order within each section so grouping never changes the project's canonical ordering.
*/
const mobileProjectGroups = useMemo(() => {
const favorites = projects.filter((project) => bookmarkedIds.has(project.id));
const others = projects.filter((project) => !bookmarkedIds.has(project.id));
return { favorites, others };
}, [bookmarkedIds, projects]);
/*
FNXC:ProjectSelector 2026-08-26-00:00:
Mobile rows use the same localStorage bookmark toggle as desktop. Stop propagation so bookmarking never selects a project or closes the switcher.
*/
const renderMobileProjectItem = (project: ProjectInfo) => {
const isCurrent = currentProject?.id === project.id;
const bookmarked = isBookmarked(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">
{getTrailingPath(project.path, 2)}
</span>
</div>
<span
role="button"
tabIndex={0}
className={`mobile-project-switch-bookmark${bookmarked ? " bookmarked" : ""}`}
onClick={(event) => {
event.stopPropagation();
toggleBookmark(project.id);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
toggleBookmark(project.id);
}
}}
aria-label={bookmarked
? t("projectSelector.removeBookmark", "Remove bookmark")
: t("projectSelector.addBookmark", "Bookmark project")}
data-testid={`mobile-bookmark-toggle-${project.id}`}
>
<Star size={14} fill={bookmarked ? "currentColor" : "none"} />
</span>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
</button>
);
};
const hasViewOverflowItems = useMemo(() => {
return !!(
@@ -418,35 +486,27 @@ export function Header({
aria-label={t("header.selectProject", "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">
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
</button>
);
})}
{mobileProjectGroups.favorites.length > 0 && (
<div data-testid="mobile-project-switch-favorites">
<div className="mobile-project-switch-section-label">
{t("header.favoriteProjects", "Favorites")}
</div>
{mobileProjectGroups.favorites.map(renderMobileProjectItem)}
</div>
)}
{mobileProjectGroups.favorites.length > 0 && mobileProjectGroups.others.length > 0 && (
<>
<div className="mobile-project-switch-divider" />
<div className="mobile-project-switch-section-label">
{t("header.allProjects", "All projects")}
</div>
</>
)}
{mobileProjectGroups.others.length > 0 && (
<div data-testid="mobile-project-switch-others">
{mobileProjectGroups.others.map(renderMobileProjectItem)}
</div>
)}
{onViewAllProjects && (
<>
<div className="mobile-project-switch-divider" />

View File

@@ -529,6 +529,40 @@
background: var(--border);
}
.mobile-project-switch-section-label {
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
font-family: var(--font-primary);
font-size: var(--font-size-xs);
font-weight: 500;
}
.mobile-project-switch-bookmark {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: var(--space-xs);
border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
}
.mobile-project-switch-bookmark:hover,
.mobile-project-switch-bookmark.bookmarked {
color: var(--accent);
}
.mobile-project-switch-bookmark:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.mobile-project-switch-bookmark:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.mobile-project-switch-manage {
width: 100%;
display: flex;
@@ -564,6 +598,12 @@
}
}
@media (max-width: 768px) {
.mobile-project-switch-bookmark {
padding: var(--space-sm);
}
}
/* === Project Content Wrapper (footer-safe layout) === */
.dashboard-project-stack {

View File

@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Header } from "../Header";
import { ProjectSelector } from "../ProjectSelector";
import type { ProjectInfo } from "../../api";
const mockFetchScripts = vi.fn();
vi.mock("../../api", async (importOriginal) => ({
...(await importOriginal<typeof import("../../api")>()),
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => "mobile",
}));
function makeProject(id: string, name: string): ProjectInfo {
return {
id,
name,
path: `/projects/${id}`,
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
const projects = [
makeProject("project-one", "Project One"),
makeProject("project-two", "Project Two"),
makeProject("project-three", "Project Three"),
];
function renderMobileHeader(onSelectProject = vi.fn()) {
const result = render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={onSelectProject}
onOpenSettings={vi.fn()}
onOpenGitHubImport={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
return { ...result, onSelectProject };
}
describe("Header mobile project favorites", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchScripts.mockResolvedValue({});
});
it("renders localStorage favorites before the remaining projects and shares the desktop bookmark store", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["project-two"]));
const { unmount } = renderMobileHeader();
const favorites = screen.getByTestId("mobile-project-switch-favorites");
const others = screen.getByTestId("mobile-project-switch-others");
expect(favorites).toHaveTextContent("Project Two");
expect([...screen.getByTestId("mobile-project-switch-dropdown").querySelectorAll("[data-testid^='mobile-project-switch-item-']")].map((item) => item.getAttribute("data-testid"))).toEqual([
"mobile-project-switch-item-project-two",
"mobile-project-switch-item-project-one",
"mobile-project-switch-item-project-three",
]);
expect(favorites.compareDocumentPosition(others) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
unmount();
render(
<ProjectSelector
projects={projects}
currentProject={projects[0]}
onSelect={vi.fn()}
onViewAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.getByText("Bookmarked").closest(".project-selector__section")).toHaveTextContent("Project Two");
});
it("omits favorite shells, labels, and the section divider when no projects are bookmarked", () => {
renderMobileHeader();
expect(screen.queryByTestId("mobile-project-switch-favorites")).toBeNull();
expect(screen.getByTestId("mobile-project-switch-others")).toBeInTheDocument();
expect(screen.queryByText("Favorites")).toBeNull();
expect(screen.queryByText("All projects")).toBeNull();
expect(screen.getByTestId("mobile-project-switch-dropdown").querySelector(".mobile-project-switch-divider")).toBeNull();
});
it("omits the all-projects section when every project is bookmarked", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(projects.map((project) => project.id)));
renderMobileHeader();
expect(screen.getByTestId("mobile-project-switch-favorites")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-project-switch-others")).toBeNull();
expect(screen.queryByText("All projects")).toBeNull();
expect(screen.getByTestId("mobile-project-switch-dropdown").querySelector(".mobile-project-switch-divider")).toBeNull();
});
it("toggles a bookmark without selecting a project or closing the switcher", async () => {
const { onSelectProject } = renderMobileHeader();
fireEvent.click(screen.getByTestId("mobile-bookmark-toggle-project-two"));
expect(onSelectProject).not.toHaveBeenCalled();
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeInTheDocument();
await waitFor(() => {
expect(JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]")).toContain("project-two");
expect(screen.getByTestId("mobile-project-switch-favorites")).toHaveTextContent("Project Two");
});
});
});