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..a96c1e0 --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,75 @@ +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, SearchResultPublic, SearchResultsPublic, User + +router = APIRouter() + + +@router.get("/", response_model=SearchResultsPublic) +def search( + session: SessionDep, + current_user: CurrentUser, + q: str = Query(default="", max_length=100), + limit: int = Query(default=10, ge=1, le=50), +) -> Any: + """ + Search across records the current user can access. + """ + query = q.strip().lower() + if not query: + return SearchResultsPublic(data=[], count=0) + + results: list[SearchResultPublic] = [] + + item_filters = [ + or_( + func.lower(Item.title).contains(query), + func.lower(func.coalesce(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(limit) + items = session.exec(item_statement).all() + for item in items: + results.append( + SearchResultPublic( + id=item.id or 0, + type="item", + title=item.title, + subtitle=item.description, + path="/items", + ) + ) + + remaining = limit - len(results) + if current_user.is_superuser and remaining > 0: + user_statement = ( + select(User) + .where( + or_( + func.lower(User.email).contains(query), + func.lower(func.coalesce(User.full_name, "")).contains(query), + ) + ) + .limit(remaining) + ) + users = session.exec(user_statement).all() + for user in users: + results.append( + SearchResultPublic( + id=user.id or 0, + type="user", + title=user.full_name or user.email, + subtitle=user.email, + path="/admin", + ) + ) + + return SearchResultsPublic(data=results, count=len(results)) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..bf56fd5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel): count: int +class SearchResultPublic(SQLModel): + id: int + type: str + title: str + subtitle: str | None = None + path: str + + +class SearchResultsPublic(SQLModel): + data: list[SearchResultPublic] + 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..aeeabb3 --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,80 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import Item, UserCreate +from app.tests.utils.user import create_random_user +from app.tests.utils.utils import random_email, random_lower_string + + +def test_search_returns_matching_items_for_current_user( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + current_user = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER) + assert current_user + assert current_user.id + + item = Item( + title="Needle bounty task", + description="Global search should find this", + owner_id=current_user.id, + ) + db.add(item) + + other_user = create_random_user(db) + assert other_user.id + hidden_item = Item( + title="Needle hidden task", + description="This belongs to another user", + owner_id=other_user.id, + ) + db.add(hidden_item) + db.commit() + + response = client.get( + f"{settings.API_V1_STR}/search/?q=needle", + headers=normal_user_token_headers, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 1 + assert content["data"][0]["type"] == "item" + assert content["data"][0]["title"] == item.title + + +def test_search_returns_users_for_superuser( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + email = random_email() + user_in = UserCreate( + email=email, + password=random_lower_string(), + full_name="Searchable Person", + ) + user = crud.create_user(session=db, user_create=user_in) + + response = client.get( + f"{settings.API_V1_STR}/search/?q=searchable", + headers=superuser_token_headers, + ) + + assert response.status_code == 200 + content = response.json() + matches = [result for result in content["data"] if result["type"] == "user"] + assert matches + assert matches[0]["id"] == user.id + assert matches[0]["title"] == user.full_name + + +def test_search_empty_query_returns_empty_results( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/search/?q=", + headers=superuser_token_headers, + ) + + 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..2c181e2 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -45,6 +45,23 @@ export type ItemsPublic = { +export type SearchResultPublic = { + id: number; + type: string; + title: string; + subtitle?: string | null; + path: 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..b9829be 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 records the current user can access. + * @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/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..1f2ed89 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,126 @@ +import { + Badge, + Box, + Flex, + Input, + InputGroup, + InputLeftElement, + Spinner, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import { useQuery } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { type FormEvent, useState } from "react" +import { FiSearch } from "react-icons/fi" + +import { type SearchResultPublic, SearchService } from "../../client" + +interface GlobalSearchProps { + onSelect?: () => void +} + +const GlobalSearch = ({ onSelect }: GlobalSearchProps) => { + const [term, setTerm] = useState("") + const navigate = useNavigate() + const query = term.trim() + const resultBg = useColorModeValue("white", "ui.darkSlate") + const borderColor = useColorModeValue("gray.200", "gray.600") + const hoverBg = useColorModeValue("gray.50", "gray.700") + const inputBg = useColorModeValue("white", "gray.700") + + const { data, isFetching } = useQuery({ + queryKey: ["global-search", query], + queryFn: () => SearchService.search({ q: query }), + enabled: query.length >= 2, + }) + + const results = data?.data ?? [] + + const openResult = (result: SearchResultPublic) => { + setTerm("") + onSelect?.() + navigate({ to: result.path }) + } + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + const firstResult = results[0] + if (firstResult) { + openResult(firstResult) + } + } + + return ( + + + + {isFetching ? : } + + setTerm(event.target.value)} + placeholder="Search" + borderRadius="8px" + bg={inputBg} + /> + + + {query.length >= 2 && ( + + {results.length > 0 ? ( + results.map((result) => ( + openResult(result)} + w="full" + textAlign="left" + px={3} + py={2} + gap={2} + align="center" + _hover={{ bg: hoverBg }} + > + + {result.type} + + + + {result.title} + + {result.subtitle && ( + + {result.subtitle} + + )} + + + )) + ) : ( + + No results + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Sidebar.tsx b/frontend/src/components/Common/Sidebar.tsx index 7582fb4..455ea6f 100644 --- a/frontend/src/components/Common/Sidebar.tsx +++ b/frontend/src/components/Common/Sidebar.tsx @@ -18,6 +18,7 @@ import { FiLogOut, FiMenu } from "react-icons/fi" import Logo from "../../assets/images/fastapi-logo.svg" import type { UserPublic } from "../../client" import useAuth from "../../hooks/useAuth" +import GlobalSearch from "./GlobalSearch" import SidebarItems from "./SidebarItems" const Sidebar = () => { @@ -53,6 +54,7 @@ const Sidebar = () => { logo + { > Logo + {currentUser?.email && (