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"); + }); + }); +});