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..997dc3d --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,79 @@ +from typing import Any + +from fastapi import APIRouter +from sqlmodel import col, or_, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, SearchResult, SearchResults, User + +router = APIRouter() + + +@router.get("/", response_model=SearchResults) +def search( + session: SessionDep, + current_user: CurrentUser, + query: str, + limit: int = 10, +) -> Any: + """ + Search visible users and items. + """ + term = query.strip() + if not term: + return SearchResults(data=[], count=0) + + like_term = f"%{term}%" + bounded_limit = min(max(limit, 1), 25) + + item_statement = select(Item).where( + or_( + col(Item.title).ilike(like_term), + col(Item.description).ilike(like_term), + ) + ) + if not current_user.is_superuser: + item_statement = item_statement.where(Item.owner_id == current_user.id) + + items = session.exec(item_statement.limit(bounded_limit)).all() + results = [ + SearchResult( + id=item.id, + type="item", + title=item.title, + subtitle=item.description, + url="/items", + ) + for item in items + if item.id is not None + ] + + remaining = bounded_limit - len(results) + if remaining > 0: + if current_user.is_superuser: + user_statement = select(User).where( + or_( + col(User.email).ilike(like_term), + col(User.full_name).ilike(like_term), + ) + ) + users = session.exec(user_statement.limit(remaining)).all() + else: + user_matches = term.lower() in current_user.email.lower() + if current_user.full_name: + user_matches = user_matches or term.lower() in current_user.full_name.lower() + users = [current_user] if user_matches else [] + + results.extend( + SearchResult( + id=user.id, + type="user", + title=user.full_name or user.email, + subtitle=user.email, + url="/admin" if current_user.is_superuser else "/settings", + ) + for user in users + if user.id is not None + ) + + return SearchResults(data=results, count=len(results)) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..ae55238 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel): count: int +class SearchResult(SQLModel): + id: int + type: str + title: str + subtitle: str | None = None + url: str + + +class SearchResults(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..fa54e6b --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,61 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import ItemCreate, UserUpdate +from app.tests.utils.user import create_random_user, user_authentication_headers +from app.tests.utils.utils import random_lower_string + + +def test_search_returns_visible_items_for_current_user( + client: TestClient, db: Session +) -> None: + password = random_lower_string() + user = create_random_user(db) + user_in = UserUpdate(password=password) + user = crud.update_user(session=db, db_user=user, user_in=user_in) + assert user.id + + item = crud.create_item( + session=db, + item_in=ItemCreate(title="Alpha invoice", description="Quarterly report"), + owner_id=user.id, + ) + other_user = create_random_user(db) + assert other_user.id + crud.create_item( + session=db, + item_in=ItemCreate(title="Alpha private", description="Other owner"), + 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={"query": "Alpha"}, + ) + + assert response.status_code == 200 + data = response.json()["data"] + assert [result["id"] for result in data if result["type"] == "item"] == [item.id] + + +def test_search_superuser_can_search_users( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + user = create_random_user(db) + user.full_name = "Needle Person" + db.add(user) + db.commit() + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"query": "Needle"}, + ) + + assert response.status_code == 200 + data = response.json()["data"] + assert any(result["type"] == "user" and result["id"] == user.id for result in data) diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..e323fa3 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -51,6 +51,23 @@ export type Message = { +export type SearchResult = { + id: number; + type: string; + title: string; + subtitle?: string | null; + url: string; +}; + + + +export type SearchResults = { + data: Array; + count: number; +}; + + + export type NewPassword = { token: string; new_password: string; diff --git a/frontend/src/client/services.ts b/frontend/src/client/services.ts index 4ace1a4..3e3ce1b 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,SearchResults,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models'; export type TDataLoginAccessToken = { formData: Body_login_login_access_token @@ -383,6 +383,39 @@ emailTo, } +export type TDataSearch = { + limit?: number +query: string + + } + +export class SearchService { + + /** + * Search + * Search visible users and items. + * @returns SearchResults Successful Response + * @throws ApiError + */ + public static search(data: TDataSearch): CancelablePromise { + const { +limit = 10, +query, +} = data; + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/', + query: { + query, 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..c6d3963 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,106 @@ +import { + Badge, + Box, + Input, + InputGroup, + InputLeftElement, + List, + ListItem, + Spinner, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import { useQuery } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { useState } from "react" +import { FiSearch } from "react-icons/fi" + +import { SearchService } from "../../client" + +const GlobalSearch = () => { + const [query, setQuery] = useState("") + const trimmedQuery = query.trim() + const shouldSearch = trimmedQuery.length >= 2 + const borderColor = useColorModeValue("gray.200", "gray.700") + const bgColor = useColorModeValue("white", "ui.darkSlate") + + const { data, isFetching } = useQuery({ + queryKey: ["global-search", trimmedQuery], + queryFn: () => SearchService.search({ query: trimmedQuery, limit: 8 }), + enabled: shouldSearch, + }) + + const results = data?.data ?? [] + + return ( + + + + {isFetching ? ( + + ) : ( + + )} + + setQuery(event.target.value)} + placeholder="Search" + bg={bgColor} + borderColor={borderColor} + shadow="sm" + /> + + + {shouldSearch && ( + + {results.length ? ( + results.map((result) => ( + setQuery("")} + > + + + {result.title} + + {result.type} + + {result.subtitle && ( + + {result.subtitle} + + )} + + )) + ) : ( + + No results found + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 9a6cfa3..1fa7f76 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,6 +1,7 @@ import { Flex, Spinner } from "@chakra-ui/react" import { Outlet, createFileRoute, redirect } from "@tanstack/react-router" +import GlobalSearch from "../components/Common/GlobalSearch" import Sidebar from "../components/Common/Sidebar" import UserMenu from "../components/Common/UserMenu" import useAuth, { isLoggedIn } from "../hooks/useAuth" @@ -29,6 +30,7 @@ function Layout() { ) : ( )} + )