From 2e9bb795442d45c85613e7b0da9120705a2e0ac2 Mon Sep 17 00:00:00 2001 From: nOBS1 <110717172+nOBS1@users.noreply.github.com> Date: Tue, 12 May 2026 23:34:21 +0800 Subject: [PATCH] Implement global search --- backend/app/api/main.py | 3 +- backend/app/api/routes/search.py | 70 +++++++++++ backend/app/models.py | 14 +++ backend/app/tests/api/routes/test_search.py | 97 ++++++++++++++++ frontend/src/client/models.ts | 16 +++ frontend/src/client/services.ts | 37 +++++- frontend/src/components/Common/Navbar.tsx | 122 ++++++++++++++++++-- 7 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 backend/app/api/routes/search.py create mode 100644 backend/app/tests/api/routes/test_search.py 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..eb16f3d --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,70 @@ +from typing import Any + +from fastapi import APIRouter +from sqlalchemy import or_ +from sqlmodel import col, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, SearchResult, SearchResultsPublic, User + +router = APIRouter() + + +@router.get("/", response_model=SearchResultsPublic) +def search( + session: SessionDep, current_user: CurrentUser, q: str, limit: int = 10 +) -> Any: + """ + Search across resources visible to the current user. + """ + query = q.strip() + if not query: + return SearchResultsPublic(data=[], count=0) + + result_limit = min(max(limit, 1), 50) + pattern = f"%{query}%" + item_filters = or_( + col(Item.title).ilike(pattern), + col(Item.description).ilike(pattern), + ) + item_statement = select(Item).where(item_filters).limit(result_limit) + + if not current_user.is_superuser: + item_statement = item_statement.where(Item.owner_id == current_user.id) + + items = session.exec(item_statement).all() + results = [ + SearchResult( + type="item", + id=item.id, + title=item.title, + description=item.description, + ) + for item in items + if item.id is not None + ] + + if current_user.is_superuser and len(results) < result_limit: + user_statement = ( + select(User) + .where( + or_( + col(User.email).ilike(pattern), + col(User.full_name).ilike(pattern), + ) + ) + .limit(result_limit - len(results)) + ) + users = session.exec(user_statement).all() + results.extend( + SearchResult( + type="user", + id=user.id, + title=user.full_name or user.email, + description=user.email, + ) + for user in users + if user.id is not None + ) + + return SearchResultsPublic(data=results, count=len(results)) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..4a322bb 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,3 +1,5 @@ +from typing import Literal + from sqlmodel import Field, Relationship, SQLModel @@ -92,6 +94,18 @@ class ItemsPublic(SQLModel): count: int +class SearchResult(SQLModel): + type: Literal["item", "user"] + id: int + title: str + description: str | None = None + + +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..432f113 --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,97 @@ +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.utils import random_email + + +def test_search_returns_items_and_users_for_superuser( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + user_in = UserCreate( + email=random_email(), + password="password", + full_name="Search Target", + ) + user = crud.create_user(session=db, user_create=user_in) + assert user.id is not None + item_in = ItemCreate( + title="Searchable bounty", + description="Global search should find this item", + ) + item = crud.create_item(session=db, item_in=item_in, owner_id=user.id) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": "search"}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] >= 2 + assert {"type": "item", "id": item.id, "title": item.title, "description": item.description} in content["data"] + assert { + "type": "user", + "id": user.id, + "title": user.full_name, + "description": user.email, + } in content["data"] + + +def test_search_limits_normal_user_to_own_items( + client: TestClient, + normal_user_token_headers: dict[str, str], + db: Session, +) -> None: + normal_user = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER) + assert normal_user + assert normal_user.id is not None + + own_item = crud.create_item( + session=db, + item_in=ItemCreate(title="Private needle", description="Visible match"), + owner_id=normal_user.id, + ) + other_user = crud.create_user( + session=db, + user_create=UserCreate(email=random_email(), password="password"), + ) + assert other_user.id is not None + crud.create_item( + session=db, + item_in=ItemCreate(title="Private needle", description="Hidden match"), + owner_id=other_user.id, + ) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=normal_user_token_headers, + params={"q": "needle"}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["data"] == [ + { + "type": "item", + "id": own_item.id, + "title": own_item.title, + "description": own_item.description, + } + ] + + +def test_search_returns_empty_results_for_blank_query( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": " "}, + ) + + assert response.status_code == 200 + assert response.json() == {"data": [], "count": 0} diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..63017d6 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -45,6 +45,22 @@ export type ItemsPublic = { +export type SearchResult = { + type: 'item' | 'user'; + id: number; + title: string; + description?: string | null; +}; + + + +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..ff47c31 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 @@ -521,4 +521,37 @@ id, }); } -} \ No newline at end of file +} + +export type TDataSearch = { + limit?: number +q: string + + } + +export class SearchService { + + /** + * Search + * Search across resources visible to the current user. + * @returns SearchResultsPublic Successful Response + * @throws ApiError + */ + public static search(data: TDataSearch): CancelablePromise { + const { +limit = 10, +q, +} = data; + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/', + query: { + q, limit + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..89dc0f3 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -1,6 +1,25 @@ -import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react" -import { FaPlus } from "react-icons/fa" +import { + Badge, + Box, + Button, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + LinkBox, + LinkOverlay, + Spinner, + Stack, + Text, + useDisclosure, +} from "@chakra-ui/react" +import { useQuery } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { useState } from "react" +import { FaPlus, FaSearch } from "react-icons/fa" +import { SearchService } from "../../client" import AddUser from "../Admin/AddUser" import AddItem from "../Items/AddItem" @@ -11,17 +30,100 @@ interface NavbarProps { const Navbar = ({ type }: NavbarProps) => { const addUserModal = useDisclosure() const addItemModal = useDisclosure() + const [searchTerm, setSearchTerm] = useState("") + const query = searchTerm.trim() + const showResults = query.length >= 2 + const searchResults = useQuery({ + queryKey: ["global-search", query], + queryFn: () => SearchService.search({ q: query }), + enabled: showResults, + }) return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + + + + + + + setSearchTerm(event.target.value)} + fontSize={{ base: "sm", md: "inherit" }} + borderRadius="8px" + /> + + {showResults && ( + + {searchResults.isLoading ? ( + + + + Searching + + + ) : ( + + {searchResults.data?.data.length ? ( + searchResults.data.data.map((result) => ( + + + + setSearchTerm("")} + > + + {result.title} + + + {result.description && ( + + {result.description} + + )} + + + {result.type} + + + + )) + ) : ( + + No results + + )} + + )} + + )} +