From fe5cda20c5d48b70cc0be616fc90e5c8108742ac Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 12 Dec 2025 11:33:52 -0600 Subject: [PATCH] Add global search and filtering enhancements - Add GET /api/v1/search endpoint for cross-entity search - Add visibility filter to projects list API - Enhance tag search to include artifact original filename - Fix project list sorting to use sort/order parameters - Add GlobalSearch component with keyboard navigation and "/" shortcut - Add FilterDropdown component for reusable filter dropdowns - Add visibility filter UI to Home page with URL persistence - Update API types and functions for global search --- backend/app/routes.py | 142 ++++++++++- backend/app/schemas.py | 45 ++++ frontend/src/api.ts | 11 +- frontend/src/components/FilterDropdown.css | 75 ++++++ frontend/src/components/FilterDropdown.tsx | 80 +++++++ frontend/src/components/GlobalSearch.css | 216 +++++++++++++++++ frontend/src/components/GlobalSearch.tsx | 265 +++++++++++++++++++++ frontend/src/components/Layout.tsx | 2 + frontend/src/components/index.ts | 3 + frontend/src/pages/Home.tsx | 37 ++- frontend/src/types.ts | 44 ++++ 11 files changed, 910 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/FilterDropdown.css create mode 100644 frontend/src/components/FilterDropdown.tsx create mode 100644 frontend/src/components/GlobalSearch.css create mode 100644 frontend/src/components/GlobalSearch.tsx diff --git a/backend/app/routes.py b/backend/app/routes.py index 711225c..2b062a1 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -28,6 +28,7 @@ from .schemas import ( ResumableUploadCompleteRequest, ResumableUploadCompleteResponse, ResumableUploadStatusResponse, + GlobalSearchResponse, SearchResultProject, SearchResultPackage, SearchResultArtifact, ) from .metadata import extract_metadata @@ -51,32 +52,155 @@ def health_check(): return HealthResponse(status="ok") +# Global search +@router.get("/api/v1/search", response_model=GlobalSearchResponse) +def global_search( + request: Request, + q: str = Query(..., min_length=1, description="Search query"), + limit: int = Query(default=5, ge=1, le=20, description="Results per type"), + db: Session = Depends(get_db), +): + """ + Search across all entity types (projects, packages, artifacts/tags). + Returns limited results for each type plus total counts. + """ + user_id = get_user_id(request) + search_lower = q.lower() + + # Search projects (name and description) + project_query = db.query(Project).filter( + or_(Project.is_public == True, Project.created_by == user_id), + or_( + func.lower(Project.name).contains(search_lower), + func.lower(Project.description).contains(search_lower) + ) + ) + project_count = project_query.count() + projects = project_query.order_by(Project.name).limit(limit).all() + + # Search packages (name and description) with project name + package_query = db.query(Package, Project.name.label("project_name")).join( + Project, Package.project_id == Project.id + ).filter( + or_(Project.is_public == True, Project.created_by == user_id), + or_( + func.lower(Package.name).contains(search_lower), + func.lower(Package.description).contains(search_lower) + ) + ) + package_count = package_query.count() + package_results = package_query.order_by(Package.name).limit(limit).all() + + # Search tags/artifacts (tag name and original filename) + artifact_query = db.query( + Tag, Artifact, Package.name.label("package_name"), Project.name.label("project_name") + ).join( + Artifact, Tag.artifact_id == Artifact.id + ).join( + Package, Tag.package_id == Package.id + ).join( + Project, Package.project_id == Project.id + ).filter( + or_(Project.is_public == True, Project.created_by == user_id), + or_( + func.lower(Tag.name).contains(search_lower), + func.lower(Artifact.original_name).contains(search_lower) + ) + ) + artifact_count = artifact_query.count() + artifact_results = artifact_query.order_by(Tag.name).limit(limit).all() + + return GlobalSearchResponse( + query=q, + projects=[SearchResultProject( + id=p.id, + name=p.name, + description=p.description, + is_public=p.is_public + ) for p in projects], + packages=[SearchResultPackage( + id=pkg.id, + project_id=pkg.project_id, + project_name=project_name, + name=pkg.name, + description=pkg.description, + format=pkg.format + ) for pkg, project_name in package_results], + artifacts=[SearchResultArtifact( + tag_id=tag.id, + tag_name=tag.name, + artifact_id=artifact.id, + package_id=tag.package_id, + package_name=package_name, + project_name=project_name, + original_name=artifact.original_name + ) for tag, artifact, package_name, project_name in artifact_results], + counts={ + "projects": project_count, + "packages": package_count, + "artifacts": artifact_count, + "total": project_count + package_count + artifact_count + } + ) + + # Project routes @router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse]) def list_projects( request: Request, page: int = Query(default=1, ge=1, description="Page number"), limit: int = Query(default=20, ge=1, le=100, description="Items per page"), - search: Optional[str] = Query(default=None, description="Search by project name"), + search: Optional[str] = Query(default=None, description="Search by project name or description"), + visibility: Optional[str] = Query(default=None, description="Filter by visibility (public, private)"), + sort: str = Query(default="name", description="Sort field (name, created_at, updated_at)"), + order: str = Query(default="asc", description="Sort order (asc, desc)"), db: Session = Depends(get_db), ): user_id = get_user_id(request) + # Validate sort field + valid_sort_fields = {"name": Project.name, "created_at": Project.created_at, "updated_at": Project.updated_at} + if sort not in valid_sort_fields: + raise HTTPException(status_code=400, detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}") + + # Validate order + if order not in ("asc", "desc"): + raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'") + # Base query - filter by access query = db.query(Project).filter( or_(Project.is_public == True, Project.created_by == user_id) ) - # Apply search filter (case-insensitive) + # Apply visibility filter + if visibility == "public": + query = query.filter(Project.is_public == True) + elif visibility == "private": + query = query.filter(Project.is_public == False, Project.created_by == user_id) + + # Apply search filter (case-insensitive on name and description) if search: - query = query.filter(func.lower(Project.name).contains(search.lower())) + search_lower = search.lower() + query = query.filter( + or_( + func.lower(Project.name).contains(search_lower), + func.lower(Project.description).contains(search_lower) + ) + ) # Get total count before pagination total = query.count() + # Apply sorting + sort_column = valid_sort_fields[sort] + if order == "desc": + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + # Apply pagination offset = (page - 1) * limit - projects = query.order_by(Project.name).offset(offset).limit(limit).all() + projects = query.offset(offset).limit(limit).all() # Calculate total pages total_pages = math.ceil(total / limit) if total > 0 else 1 @@ -882,9 +1006,15 @@ def list_tags( # Base query with JOIN to artifact for metadata query = db.query(Tag, Artifact).join(Artifact, Tag.artifact_id == Artifact.id).filter(Tag.package_id == package.id) - # Apply search filter (case-insensitive on tag name) + # Apply search filter (case-insensitive on tag name OR artifact original filename) if search: - query = query.filter(func.lower(Tag.name).contains(search.lower())) + search_lower = search.lower() + query = query.filter( + or_( + func.lower(Tag.name).contains(search_lower), + func.lower(Artifact.original_name).contains(search_lower) + ) + ) # Get total count before pagination total = query.count() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5077646..ec1af7e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -269,6 +269,51 @@ class ConsumerResponse(BaseModel): from_attributes = True +# Global search schemas +class SearchResultProject(BaseModel): + """Project result for global search""" + id: UUID + name: str + description: Optional[str] + is_public: bool + + class Config: + from_attributes = True + + +class SearchResultPackage(BaseModel): + """Package result for global search""" + id: UUID + project_id: UUID + project_name: str + name: str + description: Optional[str] + format: str + + class Config: + from_attributes = True + + +class SearchResultArtifact(BaseModel): + """Artifact/tag result for global search""" + tag_id: UUID + tag_name: str + artifact_id: str + package_id: UUID + package_name: str + project_name: str + original_name: Optional[str] + + +class GlobalSearchResponse(BaseModel): + """Combined search results across all entity types""" + query: str + projects: List[SearchResultProject] + packages: List[SearchResultPackage] + artifacts: List[SearchResultArtifact] + counts: Dict[str, int] # Total counts for each type + + # Health check class HealthResponse(BaseModel): status: str diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e4ad39e..3602f8b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,6 +11,8 @@ import { TagListParams, PackageListParams, ArtifactListParams, + ProjectListParams, + GlobalSearchResponse, } from './types'; const API_BASE = '/api/v1'; @@ -34,8 +36,15 @@ function buildQueryString(params: Record): string { return query ? `?${query}` : ''; } +// Global Search API +export async function globalSearch(query: string, limit: number = 5): Promise { + const params = buildQueryString({ q: query, limit }); + const response = await fetch(`${API_BASE}/search${params}`); + return handleResponse(response); +} + // Project API -export async function listProjects(params: ListParams = {}): Promise> { +export async function listProjects(params: ProjectListParams = {}): Promise> { const query = buildQueryString(params as Record); const response = await fetch(`${API_BASE}/projects${query}`); return handleResponse>(response); diff --git a/frontend/src/components/FilterDropdown.css b/frontend/src/components/FilterDropdown.css new file mode 100644 index 0000000..c7fe2e1 --- /dev/null +++ b/frontend/src/components/FilterDropdown.css @@ -0,0 +1,75 @@ +.filter-dropdown { + position: relative; +} + +.filter-dropdown__trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; + transition: all var(--transition-fast); +} + +.filter-dropdown__trigger:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.filter-dropdown__trigger--active { + border-color: var(--accent-primary); + color: var(--text-primary); +} + +.filter-dropdown__chevron { + transition: transform var(--transition-fast); +} + +.filter-dropdown__chevron--open { + transform: rotate(180deg); +} + +.filter-dropdown__menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 150px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 50; + overflow: hidden; +} + +.filter-dropdown__option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + color: var(--text-primary); + font-size: 0.875rem; + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); +} + +.filter-dropdown__option:hover { + background: var(--bg-hover); +} + +.filter-dropdown__option--selected { + color: var(--accent-primary); +} + +.filter-dropdown__option svg { + color: var(--accent-primary); +} diff --git a/frontend/src/components/FilterDropdown.tsx b/frontend/src/components/FilterDropdown.tsx new file mode 100644 index 0000000..8ff9e08 --- /dev/null +++ b/frontend/src/components/FilterDropdown.tsx @@ -0,0 +1,80 @@ +import { useState, useRef, useEffect } from 'react'; +import './FilterDropdown.css'; + +export interface FilterOption { + value: string; + label: string; +} + +interface FilterDropdownProps { + label: string; + options: FilterOption[]; + value: string; + onChange: (value: string) => void; + className?: string; +} + +export function FilterDropdown({ label, options, value, onChange, className = '' }: FilterDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedOption = options.find((o) => o.value === value); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/GlobalSearch.css b/frontend/src/components/GlobalSearch.css new file mode 100644 index 0000000..0c5efe8 --- /dev/null +++ b/frontend/src/components/GlobalSearch.css @@ -0,0 +1,216 @@ +.global-search { + position: relative; + flex: 1; + max-width: 400px; + margin: 0 24px; +} + +.global-search__input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.global-search__icon { + position: absolute; + left: 12px; + color: var(--text-secondary); + pointer-events: none; +} + +.global-search__input { + width: 100%; + padding: 8px 40px 8px 36px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.875rem; + transition: all var(--transition-fast); +} + +.global-search__input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); +} + +.global-search__input::placeholder { + color: var(--text-muted); +} + +.global-search__shortcut { + position: absolute; + right: 8px; + padding: 2px 6px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: inherit; + font-size: 0.75rem; + pointer-events: none; +} + +.global-search__spinner { + position: absolute; + right: 36px; + width: 14px; + height: 14px; + border: 2px solid var(--border-primary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Dropdown */ +.global-search__dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-height: 400px; + overflow-y: auto; + z-index: 1000; +} + +.global-search__empty { + padding: 24px; + text-align: center; + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* Sections */ +.global-search__section { + padding: 8px 0; + border-bottom: 1px solid var(--border-primary); +} + +.global-search__section:last-child { + border-bottom: none; +} + +.global-search__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px 8px; + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.global-search__count { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.7rem; +} + +/* Results */ +.global-search__result { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + text-align: left; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition-fast); +} + +.global-search__result:hover, +.global-search__result.selected { + background: var(--bg-hover); +} + +.global-search__result svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--text-secondary); +} + +.global-search__result-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.global-search__result-name { + font-weight: 500; + color: var(--text-primary); +} + +.global-search__result-path { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.global-search__result-desc { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Badges */ +.global-search__badge { + flex-shrink: 0; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; +} + +.global-search__badge.public { + background: rgba(16, 185, 129, 0.15); + color: var(--accent-primary); +} + +.global-search__badge.private { + background: rgba(234, 179, 8, 0.15); + color: #eab308; +} + +.global-search__badge.format { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +/* Responsive */ +@media (max-width: 768px) { + .global-search { + max-width: none; + margin: 0 12px; + } + + .global-search__shortcut { + display: none; + } +} + +@media (max-width: 640px) { + .global-search { + display: none; + } +} diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..3716d5e --- /dev/null +++ b/frontend/src/components/GlobalSearch.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { globalSearch } from '../api'; +import { GlobalSearchResponse } from '../types'; +import './GlobalSearch.css'; + +export function GlobalSearch() { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Build flat list of results for keyboard navigation + const flatResults = results + ? [ + ...results.projects.map((p) => ({ type: 'project' as const, item: p })), + ...results.packages.map((p) => ({ type: 'package' as const, item: p })), + ...results.artifacts.map((a) => ({ type: 'artifact' as const, item: a })), + ] + : []; + + const handleSearch = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + setResults(null); + setIsOpen(false); + return; + } + + setLoading(true); + try { + const data = await globalSearch(searchQuery); + setResults(data); + setIsOpen(true); + setSelectedIndex(-1); + } catch (err) { + console.error('Search failed:', err); + setResults(null); + } finally { + setLoading(false); + } + }, []); + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + handleSearch(query); + }, 300); + + return () => clearTimeout(timer); + }, [query, handleSearch]); + + // Close on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Keyboard navigation + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { + event.preventDefault(); + inputRef.current?.focus(); + } + + if (!isOpen) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, -1)); + break; + case 'Enter': + if (selectedIndex >= 0 && flatResults[selectedIndex]) { + event.preventDefault(); + navigateToResult(flatResults[selectedIndex]); + } + break; + case 'Escape': + setIsOpen(false); + inputRef.current?.blur(); + break; + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, selectedIndex, flatResults]); + + function navigateToResult(result: (typeof flatResults)[0]) { + setIsOpen(false); + setQuery(''); + + switch (result.type) { + case 'project': + navigate(`/project/${result.item.name}`); + break; + case 'package': + navigate(`/project/${result.item.project_name}/${result.item.name}`); + break; + case 'artifact': + navigate(`/project/${result.item.project_name}/${result.item.package_name}`); + break; + } + } + + const hasResults = results && results.counts.total > 0; + + return ( +
+
+ + + + + setQuery(e.target.value)} + onFocus={() => query && results && setIsOpen(true)} + placeholder="Search projects, packages, artifacts..." + className="global-search__input" + /> + / + {loading && } +
+ + {isOpen && ( +
+ {!hasResults && query && ( +
No results found for "{query}"
+ )} + + {hasResults && ( + <> + {results.projects.length > 0 && ( +
+
+ Projects + {results.counts.projects} +
+ {results.projects.map((project, index) => { + const flatIndex = index; + return ( + + ); + })} +
+ )} + + {results.packages.length > 0 && ( +
+
+ Packages + {results.counts.packages} +
+ {results.packages.map((pkg, index) => { + const flatIndex = results.projects.length + index; + return ( + + ); + })} +
+ )} + + {results.artifacts.length > 0 && ( +
+
+ Artifacts / Tags + {results.counts.artifacts} +
+ {results.artifacts.map((artifact, index) => { + const flatIndex = results.projects.length + results.packages.length + index; + return ( + + ); + })} +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3ade3a0..dc2130e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { Link, useLocation } from 'react-router-dom'; +import { GlobalSearch } from './GlobalSearch'; import './Layout.css'; interface LayoutProps { @@ -32,6 +33,7 @@ function Layout({ children }: LayoutProps) { Orchard +