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
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user