From e0ba82daddd9caf849d4672a16f1828f3560e47c Mon Sep 17 00:00:00 2001 From: sonson0910 Date: Sun, 17 May 2026 05:12:05 +0700 Subject: [PATCH] Implement global search --- backend/app/api/main.py | 3 +- backend/app/api/routes/search.py | 67 ++++++++++ backend/app/models.py | 13 ++ backend/app/tests/api/routes/test_search.py | 73 +++++++++++ frontend/src/client/models.ts | 17 +++ frontend/src/client/services.ts | 37 +++++- .../src/components/Common/GlobalSearch.tsx | 114 ++++++++++++++++++ frontend/src/components/Common/Navbar.tsx | 11 +- 8 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 backend/app/api/routes/search.py create mode 100644 backend/app/tests/api/routes/test_search.py create mode 100644 frontend/src/components/Common/GlobalSearch.tsx diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 09e0663..de2ba9f 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.routes import items, login, users, utils +from app.api.routes import items, login, search, users, utils api_router = APIRouter() api_router.include_router(login.router, tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) api_router.include_router(items.router, prefix="/items", tags=["items"]) +api_router.include_router(search.router, prefix="/search", tags=["search"]) diff --git a/backend/app/api/routes/search.py b/backend/app/api/routes/search.py new file mode 100644 index 0000000..0afa244 --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,67 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from sqlalchemy import or_ +from sqlmodel import select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, SearchResult, SearchResultsPublic, User + +router = APIRouter() + + +@router.get("/", response_model=SearchResultsPublic) +def global_search( + session: SessionDep, + current_user: CurrentUser, + q: Annotated[str, Query(min_length=1)], + limit: Annotated[int, Query(ge=1, le=50)] = 10, +) -> Any: + """ + Search across resources the current user is allowed to see. + """ + query = q.strip() + if not query: + return SearchResultsPublic(data=[], count=0) + + pattern = f"%{query}%" + item_statement = ( + select(Item) + .where(or_(Item.title.ilike(pattern), Item.description.ilike(pattern))) + .order_by(Item.id) + .limit(limit) + ) + if not current_user.is_superuser: + item_statement = item_statement.where(Item.owner_id == current_user.id) + + results: list[SearchResult] = [ + SearchResult( + resource_type="item", + id=item.id or 0, + title=item.title, + description=item.description, + path="/items", + ) + for item in session.exec(item_statement).all() + ] + + if current_user.is_superuser and len(results) < limit: + remaining = limit - len(results) + user_statement = ( + select(User) + .where(or_(User.email.ilike(pattern), User.full_name.ilike(pattern))) + .order_by(User.id) + .limit(remaining) + ) + results.extend( + SearchResult( + resource_type="user", + id=user.id or 0, + title=user.full_name or user.email, + description=user.email, + path="/admin", + ) + for user in session.exec(user_statement).all() + ) + + return SearchResultsPublic(data=results, count=len(results)) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..f710810 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel): count: int +class SearchResult(SQLModel): + resource_type: str + id: int + title: str + description: str | None = None + path: str + + +class SearchResultsPublic(SQLModel): + data: list[SearchResult] + count: int + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/tests/api/routes/test_search.py b/backend/app/tests/api/routes/test_search.py new file mode 100644 index 0000000..e95c815 --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,73 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import ItemCreate, UserCreate +from app.tests.utils.user import user_authentication_headers +from app.tests.utils.utils import random_email, random_lower_string + + +def test_search_returns_current_user_items_only( + client: TestClient, db: Session +) -> None: + password = random_lower_string() + user = crud.create_user( + session=db, user_create=UserCreate(email=random_email(), password=password) + ) + other_user = crud.create_user( + session=db, + user_create=UserCreate(email=random_email(), password=random_lower_string()), + ) + assert user.id is not None + assert other_user.id is not None + + own_item = crud.create_item( + session=db, + item_in=ItemCreate(title="Needle project", description="Public note"), + owner_id=user.id, + ) + crud.create_item( + session=db, + item_in=ItemCreate(title="Needle secret", description="Hidden note"), + owner_id=other_user.id, + ) + headers = user_authentication_headers(client=client, email=user.email, password=password) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=headers, + params={"q": "needle"}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 1 + assert content["data"][0]["resource_type"] == "item" + assert content["data"][0]["id"] == own_item.id + + +def test_search_superuser_can_find_users( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + user = crud.create_user( + session=db, + user_create=UserCreate( + email=f"search-{random_email()}", + password=random_lower_string(), + full_name="Searchable Person", + ), + ) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": "searchable"}, + ) + + assert response.status_code == 200 + content = response.json() + assert any( + result["resource_type"] == "user" and result["id"] == user.id + for result in content["data"] + ) diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..5090348 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -45,6 +45,23 @@ export type ItemsPublic = { +export type SearchResult = { + resource_type: string; + id: number; + title: string; + description?: string | null; + path: string; +}; + + + +export type SearchResultsPublic = { + data: Array; + count: number; +}; + + + export type Message = { message: string; }; diff --git a/frontend/src/client/services.ts b/frontend/src/client/services.ts index 4ace1a4..7ba73e9 100644 --- a/frontend/src/client/services.ts +++ b/frontend/src/client/services.ts @@ -2,7 +2,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models'; +import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate,SearchResultsPublic } from './models'; export type TDataLoginAccessToken = { formData: Body_login_login_access_token @@ -383,6 +383,39 @@ emailTo, } +export type TDataGlobalSearch = { + limit?: number +q: string + + } + +export class SearchService { + + /** + * Global Search + * Search across resources the current user is allowed to see. + * @returns SearchResultsPublic Successful Response + * @throws ApiError + */ + public static globalSearch(data: TDataGlobalSearch): CancelablePromise { + const { +limit = 10, +q, +} = data; + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/', + query: { + q, limit + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} + export type TDataReadItems = { limit?: number skip?: number @@ -521,4 +554,4 @@ id, }); } -} \ No newline at end of file +} diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..14c3378 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,114 @@ +import { + Badge, + Box, + Icon, + Input, + InputGroup, + InputLeftElement, + Link, + List, + ListItem, + Spinner, + Text, +} from "@chakra-ui/react" +import { useQuery } from "@tanstack/react-query" +import { useEffect, useState } from "react" +import { FaSearch } from "react-icons/fa" + +import { SearchService } from "../../client" + +const GlobalSearch = () => { + const [query, setQuery] = useState("") + const [debouncedQuery, setDebouncedQuery] = useState("") + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedQuery(query.trim()) + }, 250) + + return () => window.clearTimeout(timeoutId) + }, [query]) + + const isSearchable = debouncedQuery.length >= 2 + const { data, isFetching } = useQuery({ + queryKey: ["globalSearch", debouncedQuery], + queryFn: () => SearchService.globalSearch({ q: debouncedQuery, limit: 8 }), + enabled: isSearchable, + }) + + const showResults = query.trim().length >= 2 + const results = data?.data ?? [] + + return ( + + + + + + setQuery(event.target.value)} + fontSize={{ base: "sm", md: "inherit" }} + borderRadius="8px" + /> + + + {showResults && ( + + {isFetching ? ( + + + + ) : results.length > 0 ? ( + + {results.map((result) => ( + + setQuery("")} + > + + {result.resource_type} + + + {result.title} + + {result.description && ( + + {result.description} + + )} + + + ))} + + ) : ( + + No results + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..f7fdba8 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -3,6 +3,7 @@ import { FaPlus } from "react-icons/fa" import AddUser from "../Admin/AddUser" import AddItem from "../Items/AddItem" +import GlobalSearch from "./GlobalSearch" interface NavbarProps { type: string @@ -14,14 +15,8 @@ const Navbar = ({ type }: NavbarProps) => { return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + +