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..369e18d --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,91 @@ +from typing import Any + +from fastapi import APIRouter +from sqlmodel import col, func, 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 global_search( + session: SessionDep, + current_user: CurrentUser, + q: str, + limit: int = 10, +) -> Any: + """ + Search records visible to the current user. + """ + query = q.strip() + if not query: + return SearchResults(data=[], count=0) + + safe_limit = min(max(limit, 1), 50) + pattern = f"%{query}%" + results: list[SearchResult] = [] + + item_statement = select(Item).where( + or_( + col(Item.title).ilike(pattern), + col(Item.description).ilike(pattern), + ) + ) + if not current_user.is_superuser: + item_statement = item_statement.where(Item.owner_id == current_user.id) + + items = session.exec(item_statement.limit(safe_limit)).all() + for item in items: + results.append( + SearchResult( + id=item.id, + type="item", + title=item.title, + description=item.description, + url="/items", + ) + ) + + if current_user.is_superuser and len(results) < safe_limit: + remaining = safe_limit - len(results) + user_statement = select(User).where( + or_( + col(User.email).ilike(pattern), + col(User.full_name).ilike(pattern), + ) + ) + users = session.exec(user_statement.limit(remaining)).all() + for user in users: + results.append( + SearchResult( + id=user.id, + type="user", + title=user.full_name or user.email, + description=user.email, + url="/admin", + ) + ) + + item_count_statement = select(func.count()).select_from(Item).where( + or_( + col(Item.title).ilike(pattern), + col(Item.description).ilike(pattern), + ) + ) + if not current_user.is_superuser: + item_count_statement = item_count_statement.where(Item.owner_id == current_user.id) + + total_count = session.exec(item_count_statement).one() + + if current_user.is_superuser: + user_count_statement = select(func.count()).select_from(User).where( + or_( + col(User.email).ilike(pattern), + col(User.full_name).ilike(pattern), + ) + ) + total_count += session.exec(user_count_statement).one() + + return SearchResults(data=results, count=total_count) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..6cf6046 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 + description: 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..f952e8a --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,86 @@ +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 + + +def test_global_search_returns_visible_items( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + owner = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER) + assert owner + assert owner.id + + item_in = ItemCreate( + title="Alpha search item", + description="Visible to the normal user", + ) + crud.create_item(session=db, item_in=item_in, owner_id=owner.id) + + other_user = crud.create_user( + session=db, + user_create=UserCreate( + email="search-owner@example.com", + password="test-password", + ), + ) + assert other_user.id + other_item_in = ItemCreate( + title="Alpha hidden item", + description="Not visible to the normal user", + ) + crud.create_item(session=db, item_in=other_item_in, owner_id=other_user.id) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=normal_user_token_headers, + params={"q": "Alpha"}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 1 + assert content["data"][0]["title"] == item_in.title + assert content["data"][0]["type"] == "item" + + +def test_global_search_includes_users_for_superusers( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + user = crud.create_user( + session=db, + user_create=UserCreate( + email="global-search-user@example.com", + password="test-password", + full_name="Global Search User", + ), + ) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": "Global Search"}, + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] >= 1 + assert any( + result["id"] == user.id and result["type"] == "user" + for result in content["data"] + ) + + +def test_global_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..a48568e 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -45,6 +45,23 @@ export type ItemsPublic = { +export type SearchResult = { + id: number; + type: string; + title: string; + description?: string | null; + url: string; +}; + + + +export type SearchResults = { + 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..feab161 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,SearchResults } from './models'; export type TDataLoginAccessToken = { formData: Body_login_login_access_token @@ -521,4 +521,37 @@ id, }); } -} \ No newline at end of file +} + +export type TDataGlobalSearch = { + limit?: number +q: string + + } + +export class SearchService { + + /** + * Global Search + * Search records visible to the current user. + * @returns SearchResults 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`, + }, + }); + } + +} diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..105f2aa --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,53 @@ +import { + Input, + InputGroup, + InputLeftElement, + VisuallyHidden, +} from "@chakra-ui/react" +import { useNavigate } from "@tanstack/react-router" +import { type FormEvent, useState } from "react" +import { FaSearch } from "react-icons/fa" + +interface GlobalSearchProps { + onSearch?: () => void +} + +const GlobalSearch = ({ onSearch }: GlobalSearchProps) => { + const navigate = useNavigate() + const [query, setQuery] = useState("") + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + const trimmedQuery = query.trim() + if (!trimmedQuery) return + + navigate({ + to: "/search", + search: { q: trimmedQuery }, + }) + onSearch?.() + } + + return ( +
+ + Global search + + + + + + setQuery(event.target.value)} + placeholder="Search" + borderRadius="8px" + fontSize="sm" + /> + +
+ ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Sidebar.tsx b/frontend/src/components/Common/Sidebar.tsx index 7582fb4..ae1fce9 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 && ( diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 395866a..36f1a2f 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as LoginImport } from './routes/login' import { Route as LayoutImport } from './routes/_layout' import { Route as LayoutIndexImport } from './routes/_layout/index' import { Route as LayoutSettingsImport } from './routes/_layout/settings' +import { Route as LayoutSearchImport } from './routes/_layout/search' import { Route as LayoutItemsImport } from './routes/_layout/items' import { Route as LayoutAdminImport } from './routes/_layout/admin' @@ -52,6 +53,11 @@ const LayoutSettingsRoute = LayoutSettingsImport.update({ getParentRoute: () => LayoutRoute, } as any) +const LayoutSearchRoute = LayoutSearchImport.update({ + path: '/search', + getParentRoute: () => LayoutRoute, +} as any) + const LayoutItemsRoute = LayoutItemsImport.update({ path: '/items', getParentRoute: () => LayoutRoute, @@ -90,6 +96,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutItemsImport parentRoute: typeof LayoutImport } + '/_layout/search': { + preLoaderRoute: typeof LayoutSearchImport + parentRoute: typeof LayoutImport + } '/_layout/settings': { preLoaderRoute: typeof LayoutSettingsImport parentRoute: typeof LayoutImport @@ -107,6 +117,7 @@ export const routeTree = rootRoute.addChildren([ LayoutRoute.addChildren([ LayoutAdminRoute, LayoutItemsRoute, + LayoutSearchRoute, LayoutSettingsRoute, LayoutIndexRoute, ]), diff --git a/frontend/src/routes/_layout/search.tsx b/frontend/src/routes/_layout/search.tsx new file mode 100644 index 0000000..a519f82 --- /dev/null +++ b/frontend/src/routes/_layout/search.tsx @@ -0,0 +1,92 @@ +import { + Badge, + Box, + Container, + Heading, + LinkBox, + LinkOverlay, + SkeletonText, + Stack, + Text, +} from "@chakra-ui/react" +import { useSuspenseQuery } from "@tanstack/react-query" +import { Link, createFileRoute } from "@tanstack/react-router" +import { Suspense } from "react" + +import { SearchService } from "../../client" + +type SearchParams = { + q?: string +} + +export const Route = createFileRoute("/_layout/search")({ + validateSearch: (search: Record): SearchParams => ({ + q: typeof search.q === "string" ? search.q : "", + }), + component: Search, +}) + +const SearchResults = ({ query }: { query: string }) => { + const { data: results } = useSuspenseQuery({ + queryKey: ["global-search", query], + queryFn: () => SearchService.globalSearch({ q: query }), + }) + + if (!query) { + return ( + Enter a search term to find matching records. + ) + } + + if (results.count === 0) { + return No results found for "{query}". + } + + return ( + + + Showing {results.data.length} of {results.count} results for "{query}". + + {results.data.map((result) => ( + + + {result.type} + + + + {result.title} + + + {result.description && ( + + {result.description} + + )} + + ))} + + ) +} + +function Search() { + const { q } = Route.useSearch() + const query = q?.trim() ?? "" + + return ( + + + + Search + + + }> + + + + ) +}