feat(dashboard): scrollable, autocomplete, bookmarkable project selector
- Fix: ProjectSelector dropdown now scrolls (max-height + overflow-y) when project list exceeds visible area, with scrollIntoView on keyboard navigation - Feat: Always-visible search input with type-ahead filtering, HighlightMatch text highlighting, exact match detection + auto-select on Enter - Feat: Project bookmarking via localStorage (useProjectBookmarks hook), star toggle on each item, bookmarked section shown at top of dropdown - Refactor: Header.tsx now imports standalone ProjectSelector instead of using an inline copy that lacked these features - Tests: 131 tests passing across ProjectSelector + useProjectBookmarks
This commit is contained in:
@@ -2,8 +2,9 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent a
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react";
|
||||
import "./Header.css";
|
||||
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
|
||||
// ProjectSelector styles used by the imported standalone component.
|
||||
import "./ProjectSelector.css";
|
||||
import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
@@ -30,125 +31,8 @@ const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
|
||||
initializing: { color: "var(--info)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* ProjectSelector - A component for project navigation.
|
||||
* Shows project dropdown for switching projects and navigating to project management.
|
||||
*/
|
||||
function ProjectSelector({
|
||||
projects,
|
||||
currentProject,
|
||||
onViewAll,
|
||||
onSelectProject,
|
||||
}: {
|
||||
projects: ProjectInfo[];
|
||||
currentProject: ProjectInfo | null;
|
||||
onViewAll: () => void;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
(project: ProjectInfo) => {
|
||||
onSelectProject?.(project);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[onSelectProject]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="project-selector" ref={dropdownRef}>
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className={`project-selector-trigger${isOpen ? " project-selector-trigger--open" : ""}`}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
title={currentProject?.name ? t("header.switchProjectCurrent", "Switch project (current: {{name}})", { name: currentProject.name }) : t("header.switchProject", "Switch project")}
|
||||
aria-label={t("header.switchProject", "Switch project")}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="project-selector-trigger"
|
||||
>
|
||||
<span className="project-selector-trigger-label">
|
||||
{currentProject?.name ?? t("header.projects", "Projects")}
|
||||
</span>
|
||||
<ChevronDown size={12} className={`project-selector-chevron${isOpen ? " project-selector-chevron--open" : ""}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="project-selector-dropdown"
|
||||
role="listbox"
|
||||
aria-label={t("header.selectProject", "Select project")}
|
||||
data-testid="project-selector-dropdown"
|
||||
>
|
||||
{projects.map((project) => {
|
||||
const isCurrent = currentProject?.id === project.id;
|
||||
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`project-selector-item${isCurrent ? " project-selector-item--current" : ""}`}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
>
|
||||
<span
|
||||
className="project-selector-dot"
|
||||
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
|
||||
/>
|
||||
<div className="project-selector-info">
|
||||
<span className="project-selector-name">{project.name}</span>
|
||||
<span className="project-selector-path">
|
||||
{getTrailingPath(project.path, 2)}
|
||||
</span>
|
||||
</div>
|
||||
{isCurrent && <Check size={14} className="project-selector-check" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="project-selector-divider" role="presentation" />
|
||||
<button
|
||||
className="project-selector-manage"
|
||||
onClick={() => {
|
||||
onViewAll();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
data-testid="manage-projects-action"
|
||||
>
|
||||
{t("header.manageProjects", "Manage Projects")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Inline ProjectSelector removed — now imports StandaloneProjectSelector from ./ProjectSelector
|
||||
// which has scroll fix, autocomplete, and bookmarking features.
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -953,11 +837,11 @@ export function Header({
|
||||
|
||||
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
|
||||
{!isMobile && projects.length >= 1 && onViewAllProjects && (
|
||||
<ProjectSelector
|
||||
<StandaloneProjectSelector
|
||||
projects={projects}
|
||||
currentProject={currentProject ?? null}
|
||||
onViewAll={onViewAllProjects}
|
||||
onSelectProject={onSelectProject}
|
||||
onSelect={onSelectProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -71,11 +71,29 @@
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
max-height: min(480px, calc(100vh - 120px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--text-dim) transparent;
|
||||
}
|
||||
|
||||
.project-selector__dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.project-selector__dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.project-selector__dropdown::-webkit-scrollbar-thumb {
|
||||
background-color: var(--text-dim);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.project-selector-item,
|
||||
@@ -199,11 +217,59 @@
|
||||
}
|
||||
|
||||
.project-selector__no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) calc(var(--space-sm) + var(--space-xs));
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-selector__no-results-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Autocomplete highlight — marks the matched text substring */
|
||||
.project-selector__highlight {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--todo);
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Exact match indicator banner */
|
||||
.project-selector__exact-match {
|
||||
padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs));
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: 12px;
|
||||
color: var(--todo);
|
||||
background: color-mix(in srgb, var(--todo) 8%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Exact match badge shown on item */
|
||||
.project-selector__exact-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--todo) 15%, transparent);
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Exact match item subtle highlight */
|
||||
.project-selector__item.exact-match {
|
||||
background: color-mix(in srgb, var(--todo) 5%, transparent);
|
||||
}
|
||||
|
||||
.project-selector__footer {
|
||||
margin-top: var(--space-xs);
|
||||
padding-top: var(--space-xs);
|
||||
@@ -279,6 +345,44 @@
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
/* Bookmark star toggle */
|
||||
.project-selector__bookmark {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Show star on row hover or when bookmarked */
|
||||
.project-selector__item:hover .project-selector__bookmark,
|
||||
.project-selector__item.highlighted .project-selector__bookmark,
|
||||
.project-selector__bookmark.bookmarked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-selector__bookmark:hover {
|
||||
color: var(--todo);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.project-selector__bookmark.bookmarked {
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-selector__bookmark:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
/* Light theme overrides for project selector */
|
||||
[data-theme="light"] .project-selector-trigger:hover {
|
||||
background: var(--card-hover);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./ProjectSelector.css";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
Grid3X3,
|
||||
Search,
|
||||
Clock,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { ProjectStatus } from "@fusion/core";
|
||||
import { getTrailingPath } from "../utils/pathDisplay";
|
||||
import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig";
|
||||
import { useProjectBookmarks } from "../hooks/useProjectBookmarks";
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
projects: ProjectInfo[];
|
||||
@@ -24,14 +26,50 @@ export interface ProjectSelectorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectSelector - Project switcher dropdown with keyboard navigation
|
||||
* HighlightMatch — Renders text with matching substring highlighted (bold + accent underline).
|
||||
* Used to show which part of a project name/path matches the autocomplete query.
|
||||
*/
|
||||
function HighlightMatch({
|
||||
text,
|
||||
query,
|
||||
}: {
|
||||
text: string;
|
||||
query: string;
|
||||
}): ReactNode {
|
||||
if (!query.trim()) return <>{text}</>;
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matchIndex = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (matchIndex === -1) return <>{text}</>;
|
||||
|
||||
const before = text.slice(0, matchIndex);
|
||||
const match = text.slice(matchIndex, matchIndex + query.length);
|
||||
const after = text.slice(matchIndex + query.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<mark className="project-selector__highlight">{match}</mark>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectSelector - Project switcher dropdown with autocomplete/type-ahead
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown trigger showing current project name + chevron
|
||||
* - Always-visible search input with type-ahead filtering
|
||||
* - Text highlighting showing matched portions of project names/paths
|
||||
* - Dropdown menu with project list, status icons, "View All Projects" option
|
||||
* - Keyboard navigation: arrow keys, enter to select, escape to close
|
||||
* - Search/filter when 5+ projects
|
||||
* - Recent projects section at top (last 3 accessed)
|
||||
* - Recent projects section (last 3 accessed)
|
||||
* - Bookmarked projects section (star toggle, persisted in localStorage)
|
||||
* - Exact match detection: auto-highlights and Enter-selects the exact match
|
||||
* - No matches state with clear messaging
|
||||
*/
|
||||
export function ProjectSelector({
|
||||
projects,
|
||||
@@ -47,6 +85,8 @@ export function ProjectSelector({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
const { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks();
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
@@ -60,6 +100,7 @@ export function ProjectSelector({
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,6 +115,7 @@ export function ProjectSelector({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
@@ -82,12 +124,12 @@ export function ProjectSelector({
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus search input when dropdown opens (if search is visible)
|
||||
// Focus search input when dropdown opens (always visible for autocomplete)
|
||||
useEffect(() => {
|
||||
if (isOpen && projects.length >= 5) {
|
||||
if (isOpen) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen, projects.length]);
|
||||
}, [isOpen]);
|
||||
|
||||
// Get recent projects
|
||||
const recentProjects = useMemo(() => {
|
||||
@@ -108,28 +150,53 @@ export function ProjectSelector({
|
||||
);
|
||||
}, [projects, searchQuery]);
|
||||
|
||||
// Organize projects for display: recent first, then others
|
||||
// Detect exact match (case-insensitive name match)
|
||||
const exactMatch = useMemo((): ProjectInfo | null => {
|
||||
if (!searchQuery.trim()) return null;
|
||||
const query = searchQuery.toLowerCase();
|
||||
const nameMatches = filteredProjects.filter(
|
||||
(p) => p.name.toLowerCase() === query
|
||||
);
|
||||
if (nameMatches.length === 1) return nameMatches[0];
|
||||
return null;
|
||||
}, [filteredProjects, searchQuery]);
|
||||
|
||||
// Organize projects for display: bookmarked first, then recent, then others
|
||||
const displayProjects = useMemo(() => {
|
||||
const recentIds = new Set(recentProjects.map((p) => p.id));
|
||||
const currentId = currentProject?.id;
|
||||
|
||||
// Exclude current project from list
|
||||
// Bookmarked projects (excluding current)
|
||||
const bookmarked = filteredProjects.filter(
|
||||
(p) =>
|
||||
p.id !== currentId &&
|
||||
bookmarkedIds.has(p.id) &&
|
||||
!recentIds.has(p.id)
|
||||
);
|
||||
|
||||
// Exclude current, bookmarked, and recent from "others"
|
||||
const bookmarkedAndRecentIds = new Set([
|
||||
...bookmarked.map((p) => p.id),
|
||||
...recentIds,
|
||||
]);
|
||||
const others = filteredProjects.filter(
|
||||
(p) => p.id !== currentId && !recentIds.has(p.id)
|
||||
(p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id)
|
||||
);
|
||||
|
||||
return {
|
||||
bookmarked: searchQuery.trim() ? [] : bookmarked,
|
||||
recent: searchQuery.trim() ? [] : recentProjects,
|
||||
others,
|
||||
};
|
||||
}, [filteredProjects, recentProjects, currentProject, searchQuery]);
|
||||
}, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]);
|
||||
|
||||
// Calculate total items for keyboard navigation
|
||||
const totalItems = useMemo(() => {
|
||||
const bookmarkedCount = displayProjects.bookmarked.length;
|
||||
const recentCount = displayProjects.recent.length;
|
||||
const othersCount = displayProjects.others.length;
|
||||
const viewAllCount = 1;
|
||||
return recentCount + othersCount + viewAllCount;
|
||||
return bookmarkedCount + recentCount + othersCount + viewAllCount;
|
||||
}, [displayProjects]);
|
||||
|
||||
// Handle keyboard navigation within dropdown
|
||||
@@ -151,21 +218,30 @@ export function ProjectSelector({
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
const bookmarkedCount = displayProjects.bookmarked.length;
|
||||
const recentCount = displayProjects.recent.length;
|
||||
const othersCount = displayProjects.others.length;
|
||||
|
||||
if (highlightedIndex < recentCount) {
|
||||
if (highlightedIndex < bookmarkedCount) {
|
||||
// Select bookmarked project
|
||||
onSelect(displayProjects.bookmarked[highlightedIndex]);
|
||||
} else if (highlightedIndex < bookmarkedCount + recentCount) {
|
||||
// Select recent project
|
||||
onSelect(displayProjects.recent[highlightedIndex]);
|
||||
} else if (highlightedIndex < recentCount + othersCount) {
|
||||
onSelect(displayProjects.recent[highlightedIndex - bookmarkedCount]);
|
||||
} else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) {
|
||||
// Select other project
|
||||
onSelect(displayProjects.others[highlightedIndex - recentCount]);
|
||||
onSelect(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]);
|
||||
} else {
|
||||
// View All
|
||||
onViewAll();
|
||||
}
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
} else if (exactMatch) {
|
||||
// Auto-select exact match on Enter when nothing is highlighted
|
||||
onSelect(exactMatch);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
@@ -178,15 +254,41 @@ export function ProjectSelector({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll]
|
||||
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll, exactMatch]
|
||||
);
|
||||
|
||||
// Reset highlight when dropdown opens or search changes
|
||||
// Auto-highlight first result when filtering (type-ahead behavior)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && searchQuery.trim()) {
|
||||
if (exactMatch) {
|
||||
// Auto-highlight the exact match item
|
||||
const bookmarkedCount = displayProjects.bookmarked.length;
|
||||
const recentCount = displayProjects.recent.length;
|
||||
const matchIdx = displayProjects.others.findIndex(
|
||||
(p) => p.id === exactMatch.id
|
||||
);
|
||||
if (matchIdx >= 0) {
|
||||
setHighlightedIndex(bookmarkedCount + recentCount + matchIdx);
|
||||
}
|
||||
} else if (displayProjects.others.length > 0) {
|
||||
// Highlight first item in others section
|
||||
setHighlightedIndex(displayProjects.bookmarked.length + displayProjects.recent.length);
|
||||
} else {
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
} else if (isOpen && !searchQuery.trim()) {
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
}, [isOpen, searchQuery]);
|
||||
}, [isOpen, searchQuery, exactMatch, displayProjects]);
|
||||
|
||||
// Scroll highlighted item into view for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (highlightedIndex < 0) return;
|
||||
const el = itemRefs.current.get(highlightedIndex);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [highlightedIndex]);
|
||||
|
||||
// Handle project selection
|
||||
const handleSelectProject = useCallback(
|
||||
@@ -207,11 +309,14 @@ export function ProjectSelector({
|
||||
|
||||
// Toggle dropdown
|
||||
const toggleDropdown = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
setIsOpen((prev) => {
|
||||
if (!prev) {
|
||||
// Opening — always clear search for a fresh type-ahead
|
||||
setSearchQuery("");
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Render status icon
|
||||
const renderStatusIcon = (status: ProjectStatus) => {
|
||||
@@ -226,6 +331,36 @@ export function ProjectSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Render bookmark star toggle (span to avoid nested <button> inside listbox items)
|
||||
const renderBookmarkToggle = (projectId: string) => {
|
||||
const bookmarked = isBookmarked(projectId);
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`project-selector__bookmark ${bookmarked ? "bookmarked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBookmark(projectId);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleBookmark(projectId);
|
||||
}
|
||||
}}
|
||||
aria-label={bookmarked ? t("projectSelector.removeBookmark", "Remove bookmark") : t("projectSelector.addBookmark", "Bookmark project")}
|
||||
data-testid={`bookmark-toggle-${projectId}`}
|
||||
>
|
||||
<Star
|
||||
size={14}
|
||||
fill={bookmarked ? "currentColor" : "none"}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render if only one project (single-project mode)
|
||||
if (projects.length <= 1) {
|
||||
return null;
|
||||
@@ -262,40 +397,51 @@ export function ProjectSelector({
|
||||
onKeyDown={handleDropdownKeyDown}
|
||||
data-testid="project-selector-dropdown"
|
||||
>
|
||||
{/* Search input (shown when 5+ projects) */}
|
||||
{projects.length >= 5 && (
|
||||
<div className="project-selector__search">
|
||||
<Search size={14} className="project-selector__search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={t("projectSelector.searchPlaceholder", "Search projects...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="project-selector__search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="project-selector__search-clear"
|
||||
onClick={() => setSearchQuery("")}
|
||||
aria-label={t("projectSelector.clearSearch", "Clear search")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* Search input — always visible for autocomplete/type-ahead */}
|
||||
<div className="project-selector__search">
|
||||
<Search size={14} className="project-selector__search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={t("projectSelector.searchPlaceholder", "Search projects...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="project-selector__search-input"
|
||||
data-testid="project-selector-search-input"
|
||||
aria-label={t("projectSelector.searchAriaLabel", "Type to search projects")}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="project-selector__search-clear"
|
||||
onClick={() => setSearchQuery("")}
|
||||
aria-label={t("projectSelector.clearSearch", "Clear search")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exact match indicator */}
|
||||
{exactMatch && (
|
||||
<div className="project-selector__exact-match" data-testid="project-selector-exact-match">
|
||||
{t("projectSelector.exactMatch", "Exact match — press Enter to select")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent projects section */}
|
||||
{displayProjects.recent.length > 0 && (
|
||||
{/* Bookmarked projects section */}
|
||||
{displayProjects.bookmarked.length > 0 && (
|
||||
<div className="project-selector__section">
|
||||
<div className="project-selector__section-header">
|
||||
<Clock size={12} />
|
||||
<span>{t("projectSelector.recent", "Recent")}</span>
|
||||
<Star size={12} fill="currentColor" />
|
||||
<span>{t("projectSelector.bookmarked", "Bookmarked")}</span>
|
||||
</div>
|
||||
{displayProjects.recent.map((project, index) => (
|
||||
{displayProjects.bookmarked.map((project, index) => (
|
||||
<button
|
||||
key={project.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
else itemRefs.current.delete(index);
|
||||
}}
|
||||
className={`project-selector__item ${
|
||||
highlightedIndex === index ? "highlighted" : ""
|
||||
}`}
|
||||
@@ -307,6 +453,7 @@ export function ProjectSelector({
|
||||
<span className="project-selector__item-name">
|
||||
{project.name}
|
||||
</span>
|
||||
{renderBookmarkToggle(project.id)}
|
||||
{currentProject?.id === project.id && (
|
||||
<Check size={14} className="project-selector__item-check" />
|
||||
)}
|
||||
@@ -315,41 +462,98 @@ export function ProjectSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All projects section */}
|
||||
<div className="project-selector__section">
|
||||
{displayProjects.recent.length > 0 && (
|
||||
{/* Recent projects section */}
|
||||
{displayProjects.recent.length > 0 && (
|
||||
<div className="project-selector__section">
|
||||
<div className="project-selector__section-header">
|
||||
<Folder size={12} />
|
||||
<span>{t("projectSelector.allProjects", "All Projects")}</span>
|
||||
<Clock size={12} />
|
||||
<span>{t("projectSelector.recent", "Recent")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayProjects.others.length === 0 && searchQuery ? (
|
||||
<div className="project-selector__no-results">
|
||||
{t("projectSelector.noResults", "No projects match your search")}
|
||||
</div>
|
||||
) : (
|
||||
displayProjects.others.map((project, index) => {
|
||||
const actualIndex = displayProjects.recent.length + index;
|
||||
{displayProjects.recent.map((project, index) => {
|
||||
const actualIndex = displayProjects.bookmarked.length + index;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(actualIndex, el);
|
||||
else itemRefs.current.delete(actualIndex);
|
||||
}}
|
||||
className={`project-selector__item ${
|
||||
highlightedIndex === actualIndex ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
role="option"
|
||||
aria-selected={currentProject?.id === project.id}
|
||||
>
|
||||
{renderStatusIcon(project.status)}
|
||||
<span className="project-selector__item-name">
|
||||
{project.name}
|
||||
</span>
|
||||
{renderBookmarkToggle(project.id)}
|
||||
{currentProject?.id === project.id && (
|
||||
<Check size={14} className="project-selector__item-check" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All projects section */}
|
||||
<div className="project-selector__section">
|
||||
{(displayProjects.bookmarked.length > 0 || displayProjects.recent.length > 0) && (
|
||||
<div className="project-selector__section-header">
|
||||
<Folder size={12} />
|
||||
<span>{t("projectSelector.allProjects", "All Projects")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayProjects.others.length === 0 && searchQuery ? (
|
||||
<div className="project-selector__no-results" data-testid="project-selector-no-results">
|
||||
<Search size={14} className="project-selector__no-results-icon" />
|
||||
<span>
|
||||
{t("projectSelector.noResults", "No projects match \"{{query}}\"", { query: searchQuery })}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
displayProjects.others.map((project, index) => {
|
||||
const actualIndex = displayProjects.bookmarked.length + displayProjects.recent.length + index;
|
||||
const isExactMatch = exactMatch?.id === project.id;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(actualIndex, el);
|
||||
else itemRefs.current.delete(actualIndex);
|
||||
}}
|
||||
className={`project-selector__item ${
|
||||
highlightedIndex === actualIndex ? "highlighted" : ""
|
||||
} ${isExactMatch ? "exact-match" : ""}`}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
role="option"
|
||||
aria-selected={currentProject?.id === project.id}
|
||||
data-testid={`project-selector-item-${project.id}`}
|
||||
>
|
||||
{renderStatusIcon(project.status)}
|
||||
<div className="project-selector__item-info">
|
||||
<span className="project-selector__item-name">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="project-selector__item-path">
|
||||
{getTrailingPath(project.path, 2)}
|
||||
<HighlightMatch text={project.name} query={searchQuery} />
|
||||
</span>
|
||||
{project.path && (
|
||||
<span className="project-selector__item-path">
|
||||
<HighlightMatch
|
||||
text={getTrailingPath(project.path, 2)}
|
||||
query={searchQuery}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExactMatch && (
|
||||
<span className="project-selector__exact-badge">
|
||||
{t("projectSelector.exact", "Exact")}
|
||||
</span>
|
||||
)}
|
||||
{renderBookmarkToggle(project.id)}
|
||||
{currentProject?.id === project.id && (
|
||||
<Check size={14} className="project-selector__item-check" />
|
||||
)}
|
||||
@@ -362,6 +566,11 @@ export function ProjectSelector({
|
||||
{/* View All option */}
|
||||
<div className="project-selector__footer">
|
||||
<button
|
||||
ref={(el) => {
|
||||
const viewAllIndex = totalItems - 1;
|
||||
if (el) itemRefs.current.set(viewAllIndex, el);
|
||||
else itemRefs.current.delete(viewAllIndex);
|
||||
}}
|
||||
className={`project-selector__view-all ${
|
||||
highlightedIndex === totalItems - 1 ? "highlighted" : ""
|
||||
}`}
|
||||
|
||||
@@ -3,6 +3,18 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ProjectSelector } from "../ProjectSelector";
|
||||
import type { ProjectInfo, ProjectStatus } from "@fusion/core";
|
||||
|
||||
// Mock useProjectBookmarks
|
||||
const mockToggleBookmark = vi.fn();
|
||||
let mockBookmarkedIds: Set<string> = new Set();
|
||||
|
||||
vi.mock("../../hooks/useProjectBookmarks", () => ({
|
||||
useProjectBookmarks: () => ({
|
||||
bookmarkedIds: mockBookmarkedIds,
|
||||
toggleBookmark: mockToggleBookmark,
|
||||
isBookmarked: (id: string) => mockBookmarkedIds.has(id),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
@@ -14,6 +26,9 @@ vi.mock("lucide-react", async () => {
|
||||
Grid3X3: () => <span data-testid="grid-icon">⊞</span>,
|
||||
Search: () => <span data-testid="search-icon">🔍</span>,
|
||||
Clock: () => <span data-testid="clock-icon">🕐</span>,
|
||||
Star: ({ fill }: { fill?: string }) => (
|
||||
<span data-testid="star-icon" data-fill={fill ?? "none"}>★</span>
|
||||
),
|
||||
X: () => <span data-testid="x-icon">✕</span>,
|
||||
Play: () => <span data-testid="play-icon">▶</span>,
|
||||
Pause: () => <span data-testid="pause-icon">⏸</span>,
|
||||
@@ -41,6 +56,9 @@ const noop = () => {};
|
||||
describe("ProjectSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBookmarkedIds = new Set();
|
||||
// JSDOM does not implement scrollIntoView — mock it for itemRefs
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -231,14 +249,12 @@ describe("ProjectSelector", () => {
|
||||
expect(screen.getByPlaceholderText("Search projects...")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show search input when fewer than 5 projects", () => {
|
||||
it("shows search input even with only 2 projects (autocomplete always available)", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }),
|
||||
makeProject({ id: "proj_2" }),
|
||||
makeProject({ id: "proj_3" }),
|
||||
makeProject({ id: "proj_4" }),
|
||||
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Beta" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
@@ -247,7 +263,8 @@ describe("ProjectSelector", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.queryByPlaceholderText("Search projects...")).toBeNull();
|
||||
// Search input should always be present for autocomplete
|
||||
expect(screen.getByTestId("project-selector-search-input")).toBeDefined();
|
||||
});
|
||||
|
||||
it("filters projects based on search query", () => {
|
||||
@@ -370,4 +387,421 @@ describe("ProjectSelector", () => {
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Should have checkmark for current project (though in dropdown it might not be visible due to filtering)
|
||||
});
|
||||
|
||||
// === Bookmark-specific tests ===
|
||||
|
||||
describe("bookmarks", () => {
|
||||
it("shows bookmark toggle on each project item", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
expect(bookmarkToggle).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls toggleBookmark when star is clicked", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
fireEvent.click(bookmarkToggle);
|
||||
expect(mockToggleBookmark).toHaveBeenCalledWith("proj_2");
|
||||
});
|
||||
|
||||
it("does not trigger onSelect when star is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={onSelect}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
fireEvent.click(bookmarkToggle);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Bookmarked section when projects are bookmarked", () => {
|
||||
mockBookmarkedIds = new Set(["proj_2"]);
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
makeProject({ id: "proj_3", name: "Project Three" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.getByText("Bookmarked")).toBeDefined();
|
||||
expect(screen.getByText("Project Two")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays bookmarked projects at top of list", () => {
|
||||
mockBookmarkedIds = new Set(["proj_3"]);
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
makeProject({ id: "proj_3", name: "Project Three" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
|
||||
// "Bookmarked" section header should exist
|
||||
expect(screen.getByText("Bookmarked")).toBeDefined();
|
||||
|
||||
// The "All Projects" section should also exist since we have non-bookmarked items
|
||||
expect(screen.getByText("All Projects")).toBeDefined();
|
||||
});
|
||||
|
||||
it("bookmark toggle shows filled star for bookmarked projects", () => {
|
||||
mockBookmarkedIds = new Set(["proj_2"]);
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const bookmarkedStar = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
// Star inside should have data-fill="currentColor" for bookmarked
|
||||
const starIcon = bookmarkedStar.querySelector('[data-testid="star-icon"]');
|
||||
expect(starIcon?.getAttribute("data-fill")).toBe("currentColor");
|
||||
});
|
||||
|
||||
it("does not show Bookmarked section when no projects are bookmarked", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(screen.queryByText("Bookmarked")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show current project in bookmarked section", () => {
|
||||
mockBookmarkedIds = new Set(["proj_1"]);
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Current project should not appear in Bookmarked section
|
||||
// (it's excluded from all lists)
|
||||
expect(screen.queryByText("Bookmarked")).toBeNull();
|
||||
});
|
||||
|
||||
it("bookmark toggle has correct aria-label", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const toggle = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
expect(toggle.getAttribute("aria-label")).toBe("Bookmark project");
|
||||
});
|
||||
|
||||
it("bookmarked project toggle shows remove aria-label", () => {
|
||||
mockBookmarkedIds = new Set(["proj_2"]);
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const toggle = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
expect(toggle.getAttribute("aria-label")).toBe("Remove bookmark");
|
||||
});
|
||||
|
||||
it("dropdown stays open when bookmark is toggled", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
|
||||
fireEvent.click(bookmarkToggle);
|
||||
// Dropdown should still be open
|
||||
expect(screen.getByTestId("project-selector-dropdown")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// === Autocomplete-specific tests ===
|
||||
|
||||
describe("autocomplete", () => {
|
||||
it("highlights matching text in project names", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Project Beta" }),
|
||||
makeProject({ id: "proj_3", name: "Unrelated" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "Beta" } });
|
||||
|
||||
// Should show the matched project with highlighted text
|
||||
const mark = screen.getByText("Beta");
|
||||
expect(mark.tagName).toBe("MARK");
|
||||
expect(mark.closest(".project-selector__item")).toBeDefined();
|
||||
});
|
||||
|
||||
it("detects exact match and shows indicator", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "My App" }),
|
||||
makeProject({ id: "proj_2", name: "My Application" }),
|
||||
makeProject({ id: "proj_3", name: "Other" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_3" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "My App" } });
|
||||
|
||||
// Should show exact match indicator
|
||||
expect(screen.getByTestId("project-selector-exact-match")).toBeDefined();
|
||||
// Should show "Exact" badge on the matching item
|
||||
expect(screen.getByText("Exact")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show exact match indicator for partial match", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "My App" }),
|
||||
makeProject({ id: "proj_2", name: "My Application" }),
|
||||
makeProject({ id: "proj_3", name: "Other" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_3" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
// "My" matches multiple projects — not an exact match
|
||||
fireEvent.change(searchInput, { target: { value: "My" } });
|
||||
|
||||
expect(screen.queryByTestId("project-selector-exact-match")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-selects exact match on Enter when nothing is highlighted", () => {
|
||||
const onSelect = vi.fn();
|
||||
const exactProject = makeProject({ id: "proj_1", name: "Unique Project" });
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
exactProject,
|
||||
makeProject({ id: "proj_2", name: "Other Project" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_2" })}
|
||||
onSelect={onSelect}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "Unique Project" } });
|
||||
|
||||
// Press Enter without highlighting anything first
|
||||
fireEvent.keyDown(screen.getByTestId("project-selector-dropdown"), { key: "Enter" });
|
||||
expect(onSelect).toHaveBeenCalledWith(exactProject);
|
||||
});
|
||||
|
||||
it("shows no results message when search matches nothing", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Beta" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "zzznonexistent" } });
|
||||
|
||||
expect(screen.getByTestId("project-selector-no-results")).toBeDefined();
|
||||
});
|
||||
|
||||
it("clear button clears search query", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Beta" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "Alpha" } });
|
||||
|
||||
// Clear button should appear
|
||||
const clearButton = screen.getByLabelText("Clear search");
|
||||
expect(clearButton).toBeDefined();
|
||||
|
||||
fireEvent.click(clearButton);
|
||||
expect(searchInput).toHaveValue("");
|
||||
});
|
||||
|
||||
it("filters by project path as well as name", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Alpha", path: "/home/user/projects/frontend" }),
|
||||
makeProject({ id: "proj_2", name: "Beta", path: "/home/user/projects/backend" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "backend" } });
|
||||
|
||||
// Beta project should be visible (matched by path)
|
||||
expect(screen.getByText("Beta")).toBeDefined();
|
||||
// Alpha should be filtered out
|
||||
expect(screen.queryByText("Alpha")).toBeNull();
|
||||
});
|
||||
|
||||
it("resets search query when dropdown is closed and reopened", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Beta" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open, type something, close with escape
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "Alpha" } });
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
// Reopen — search should be cleared
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const reopenedSearchInput = screen.getByPlaceholderText("Search projects...");
|
||||
expect(reopenedSearchInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useProjectBookmarks } from "../useProjectBookmarks";
|
||||
|
||||
describe("useProjectBookmarks", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("starts with empty bookmarks when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
expect(result.current.bookmarkedIds.size).toBe(0);
|
||||
expect(result.current.isBookmarked("any-id")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads bookmarks from localStorage", () => {
|
||||
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", "proj_2"]));
|
||||
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
expect(result.current.bookmarkedIds.size).toBe(2);
|
||||
expect(result.current.isBookmarked("proj_1")).toBe(true);
|
||||
expect(result.current.isBookmarked("proj_2")).toBe(true);
|
||||
expect(result.current.isBookmarked("proj_3")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds a bookmark", () => {
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_1");
|
||||
});
|
||||
|
||||
expect(result.current.isBookmarked("proj_1")).toBe(true);
|
||||
expect(result.current.bookmarkedIds.has("proj_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes a bookmark", () => {
|
||||
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1"]));
|
||||
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_1");
|
||||
});
|
||||
|
||||
expect(result.current.isBookmarked("proj_1")).toBe(false);
|
||||
expect(result.current.bookmarkedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("persists bookmarks to localStorage", () => {
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_1");
|
||||
});
|
||||
|
||||
// Check that localStorage was updated
|
||||
const stored = JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]");
|
||||
expect(stored).toContain("proj_1");
|
||||
});
|
||||
|
||||
it("persists removal to localStorage", () => {
|
||||
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", "proj_2"]));
|
||||
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_1");
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]");
|
||||
expect(stored).toEqual(["proj_2"]);
|
||||
});
|
||||
|
||||
it("handles corrupted localStorage gracefully", () => {
|
||||
localStorage.setItem("fusion_project_bookmarks", "not-json{{{");
|
||||
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
expect(result.current.bookmarkedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles non-array localStorage value gracefully", () => {
|
||||
localStorage.setItem("fusion_project_bookmarks", JSON.stringify({ foo: "bar" }));
|
||||
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
expect(result.current.bookmarkedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters non-string items from stored array", () => {
|
||||
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", 42, null, "proj_2"]));
|
||||
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
expect(result.current.bookmarkedIds.size).toBe(2);
|
||||
expect(result.current.isBookmarked("proj_1")).toBe(true);
|
||||
expect(result.current.isBookmarked("proj_2")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles multiple bookmarks independently", () => {
|
||||
const { result } = renderHook(() => useProjectBookmarks());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_1");
|
||||
});
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_2");
|
||||
});
|
||||
|
||||
expect(result.current.isBookmarked("proj_1")).toBe(true);
|
||||
expect(result.current.isBookmarked("proj_2")).toBe(true);
|
||||
expect(result.current.bookmarkedIds.size).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleBookmark("proj_1");
|
||||
});
|
||||
|
||||
expect(result.current.isBookmarked("proj_1")).toBe(false);
|
||||
expect(result.current.isBookmarked("proj_2")).toBe(true);
|
||||
expect(result.current.bookmarkedIds.size).toBe(1);
|
||||
});
|
||||
});
|
||||
52
packages/dashboard/app/hooks/useProjectBookmarks.ts
Normal file
52
packages/dashboard/app/hooks/useProjectBookmarks.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
|
||||
const STORAGE_KEY = "fusion_project_bookmarks";
|
||||
|
||||
/**
|
||||
* Manages project bookmark IDs persisted in localStorage.
|
||||
* Bookmarks are stored as a JSON array of project ID strings.
|
||||
*/
|
||||
export function useProjectBookmarks() {
|
||||
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed.filter((id: unknown) => typeof id === "string"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupted or missing — start empty
|
||||
}
|
||||
return new Set<string>();
|
||||
});
|
||||
|
||||
// Sync to localStorage whenever the set changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...bookmarkedIds]));
|
||||
} catch {
|
||||
// localStorage full or unavailable — non-critical, ignore
|
||||
}
|
||||
}, [bookmarkedIds]);
|
||||
|
||||
const toggleBookmark = useCallback((projectId: string) => {
|
||||
setBookmarkedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId);
|
||||
} else {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isBookmarked = useCallback(
|
||||
(projectId: string) => bookmarkedIds.has(projectId),
|
||||
[bookmarkedIds],
|
||||
);
|
||||
|
||||
return { bookmarkedIds, toggleBookmark, isBookmarked };
|
||||
}
|
||||
Reference in New Issue
Block a user