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:
7
.changeset/mobile-project-favorites.md
Normal file
7
.changeset/mobile-project-favorites.md
Normal 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`).
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user