From 147398f2c8af45bec34be892cc8bbe6ed7810ff5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 21:55:54 -0700 Subject: [PATCH] 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) --- .changeset/mobile-project-favorites.md | 7 + packages/dashboard/app/components/Header.tsx | 120 +++++++++++++----- .../app/components/ProjectSelector.css | 40 ++++++ .../Header.mobile-project-favorites.test.tsx | 116 +++++++++++++++++ 4 files changed, 253 insertions(+), 30 deletions(-) create mode 100644 .changeset/mobile-project-favorites.md create mode 100644 packages/dashboard/app/components/__tests__/Header.mobile-project-favorites.test.tsx diff --git a/.changeset/mobile-project-favorites.md b/.changeset/mobile-project-favorites.md new file mode 100644 index 0000000000..5665bd7502 --- /dev/null +++ b/.changeset/mobile-project-favorites.md @@ -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`). diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 31a92591ec..0b8856fc30 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -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 ( + + ); + }; 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 ( - - ); - })} + {mobileProjectGroups.favorites.length > 0 && ( +
+
+ {t("header.favoriteProjects", "Favorites")} +
+ {mobileProjectGroups.favorites.map(renderMobileProjectItem)} +
+ )} + {mobileProjectGroups.favorites.length > 0 && mobileProjectGroups.others.length > 0 && ( + <> +
+
+ {t("header.allProjects", "All projects")} +
+ + )} + {mobileProjectGroups.others.length > 0 && ( +
+ {mobileProjectGroups.others.map(renderMobileProjectItem)} +
+ )} {onViewAllProjects && ( <>
diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index 051e899595..cad4e372d6 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -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 { diff --git a/packages/dashboard/app/components/__tests__/Header.mobile-project-favorites.test.tsx b/packages/dashboard/app/components/__tests__/Header.mobile-project-favorites.test.tsx new file mode 100644 index 0000000000..86df8fbb40 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Header.mobile-project-favorites.test.tsx @@ -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()), + 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( +
, + ); + 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( + , + ); + 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"); + }); + }); +});