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..c6bd228 --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,84 @@ +from typing import Any + +from fastapi import APIRouter, Query +from sqlalchemy import or_ +from sqlmodel import func, 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 = Query(min_length=1, max_length=100), + limit: int = Query(default=10, ge=1, le=50), +) -> Any: + """ + Search across resources visible to the current user. + """ + query = q.strip().lower() + if not query: + return SearchResultsPublic(data=[], count=0) + + results: list[SearchResult] = [] + item_limit = limit if not current_user.is_superuser else max(1, limit // 2) + user_limit = limit - item_limit + + item_filters = [ + or_( + func.lower(Item.title).contains(query), + func.lower(Item.description).contains(query), + ) + ] + if not current_user.is_superuser: + item_filters.append(Item.owner_id == current_user.id) + + item_statement = select(Item).where(*item_filters).limit(item_limit) + for item in session.exec(item_statement): + results.append( + SearchResult( + type="item", + id=item.id, + title=item.title, + description=item.description, + url="/items", + ) + ) + + if current_user.is_superuser: + user_statement = ( + select(User) + .where( + or_( + func.lower(User.email).contains(query), + func.lower(User.full_name).contains(query), + ) + ) + .limit(user_limit) + ) + users = session.exec(user_statement) + else: + users = [current_user] if _matches_user(current_user, query) else [] + + for user in users: + results.append( + SearchResult( + type="user", + id=user.id, + title=user.full_name or user.email, + description=user.email, + url="/admin" if current_user.is_superuser else "/settings", + ) + ) + + return SearchResultsPublic(data=results[:limit], count=len(results)) + + +def _matches_user(user: User, query: str) -> bool: + return query in user.email.lower() or ( + user.full_name is not None and query in user.full_name.lower() + ) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..1c47188 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel): count: int +class SearchResult(SQLModel): + type: str + id: int + title: str + description: str | None = None + url: 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..30eba1c --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,37 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.item import create_random_item + + +def test_search_items_for_superuser( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db, title="Searchable bounty task") + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": "bounty"}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] >= 1 + assert any( + result["type"] == "item" and result["id"] == item.id + for result in content["data"] + ) + + +def test_search_requires_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 == 422 diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..84243ea 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -45,6 +45,23 @@ export type ItemsPublic = { +export type SearchResult = { + type: string; + id: number; + title: string; + description?: string | null; + url: string; +}; + + + +export type SearchResultsPublic = { + data: Array; + count: number; +}; + + + export type Message = { message: string; }; @@ -129,4 +146,3 @@ export type ValidationError = { msg: string; type: 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..cf02c58 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -1,6 +1,24 @@ -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, + 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 +29,85 @@ interface NavbarProps { const Navbar = ({ type }: NavbarProps) => { const addUserModal = useDisclosure() const addItemModal = useDisclosure() + const [query, setQuery] = useState("") + const trimmedQuery = query.trim() + + const { data: searchResults, isFetching } = useQuery({ + queryKey: ["global-search", trimmedQuery], + queryFn: () => SearchService.search({ q: trimmedQuery }), + enabled: trimmedQuery.length > 0, + }) return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + + + + + + + setQuery(event.target.value)} + placeholder="Search" + fontSize={{ base: "sm", md: "inherit" }} + borderRadius="8px" + /> + + {trimmedQuery.length > 0 && ( + + {isFetching ? ( + + + Searching + + ) : searchResults?.data.length ? ( + searchResults.data.map((result) => ( + + + + + + {result.title} + + + {result.description && ( + + {result.description} + + )} + + {result.type} + + + )) + ) : ( + + No results + + )} + + )} +