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..bd4d621 --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,70 @@ +from typing import Any + +from fastapi import APIRouter, Query +from sqlalchemy import or_ +from sqlmodel import col, 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, + q: str = Query(min_length=1, max_length=100), + limit: int = Query(default=8, ge=1, le=25), +) -> Any: + """ + Search records visible to the current user. + """ + query = q.strip() + if not query: + return SearchResults(data=[], count=0) + + pattern = f"%{query}%" + 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(limit)).all() + results: list[SearchResult] = [ + SearchResult( + id=f"item-{item.id}", + title=item.title, + description=item.description or f"Item #{item.id}", + category="Item", + path="/items", + ) + for item in items + ] + + if current_user.is_superuser and len(results) < limit: + remaining = 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() + results.extend( + SearchResult( + id=f"user-{user.id}", + title=user.full_name or user.email, + description=f"{user.email} - " + f"{'Superuser' if user.is_superuser else 'User'}", + category="User", + path="/admin", + ) + for user in users + ) + + return SearchResults(data=results, count=len(results)) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..97abcb2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel): count: int +class SearchResult(SQLModel): + id: str + title: str + description: str | None = None + category: str + path: 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..90d2f61 --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,83 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import ItemCreate +from app.tests.utils.user import create_random_user + + +def test_search_returns_visible_items_for_normal_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 is not None + assert current_user.id is not None + + visible_item = crud.create_item( + session=db, + item_in=ItemCreate( + title="Visible Alpha Search Item", + description="Normal user can find this", + ), + owner_id=current_user.id, + ) + other_user = create_random_user(db) + assert other_user.id is not None + crud.create_item( + session=db, + item_in=ItemCreate( + title="Hidden Alpha Search Item", + description="Normal user cannot find this", + ), + owner_id=other_user.id, + ) + + response = client.get( + f"{settings.API_V1_STR}/search/", + headers=normal_user_token_headers, + params={"q": "Alpha Search"}, + ) + + assert response.status_code == 200 + content = response.json() + result_ids = {result["id"] for result in content["data"]} + assert f"item-{visible_item.id}" in result_ids + assert all(result["title"] != "Hidden Alpha Search Item" for result in content["data"]) + + +def test_search_returns_users_only_for_superuser( + client: TestClient, + superuser_token_headers: dict[str, str], + normal_user_token_headers: dict[str, str], + db: Session, +) -> None: + user = create_random_user(db) + user.full_name = "Unique Search Person" + db.add(user) + db.commit() + db.refresh(user) + + superuser_response = client.get( + f"{settings.API_V1_STR}/search/", + headers=superuser_token_headers, + params={"q": "Unique Search Person"}, + ) + normal_user_response = client.get( + f"{settings.API_V1_STR}/search/", + headers=normal_user_token_headers, + params={"q": "Unique Search Person"}, + ) + + assert superuser_response.status_code == 200 + assert normal_user_response.status_code == 200 + assert any( + result["id"] == f"user-{user.id}" + for result in superuser_response.json()["data"] + ) + assert all( + result["id"] != f"user-{user.id}" + for result in normal_user_response.json()["data"] + ) diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..29b9f88 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -43,6 +43,19 @@ export type ItemsPublic = { count: number; }; +export type SearchResult = { + id: string; + title: string; + description?: string | null; + category: string; + path: string; +}; + +export type SearchResults = { + data: Array; + count: number; +}; + export type Message = { @@ -129,4 +142,3 @@ export type ValidationError = { msg: string; type: string; }; - diff --git a/frontend/src/client/services.ts b/frontend/src/client/services.ts index 4ace1a4..21bfd6f 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,38 @@ emailTo, } +export type TDataSearch = { + limit?: number +q: string + } + +export class SearchService { + + /** + * Search + * Search records visible to the current user. + * @returns SearchResults Successful Response + * @throws ApiError + */ + public static search(data: TDataSearch): CancelablePromise { + const { +limit = 8, +q, +} = data; + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/', + query: { + q, limit + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} + export type TDataReadItems = { limit?: number skip?: number @@ -521,4 +553,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..baa1581 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,201 @@ +import { + Badge, + Box, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + Spinner, + Text, + useColorModeValue, +} from "@chakra-ui/react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { useMemo, useState } from "react" +import { FaSearch } from "react-icons/fa" + +import { SearchService, type UserPublic } from "../../client" + +type SearchResult = { + id: string + title: string + description: string + category: "Page" | "Item" | "User" + path: string +} + +const matchesQuery = ( + query: string, + values: Array, +) => { + return values.some((value) => + String(value ?? "") + .toLowerCase() + .includes(query), + ) +} + +const GlobalSearch = () => { + const [query, setQuery] = useState("") + const [isFocused, setIsFocused] = useState(false) + const queryClient = useQueryClient() + const currentUser = queryClient.getQueryData(["currentUser"]) + const normalizedQuery = query.trim().toLowerCase() + const hasQuery = normalizedQuery.length > 0 + + const resultsBg = useColorModeValue("white", "ui.darkSlate") + const resultHoverBg = useColorModeValue("gray.50", "whiteAlpha.100") + const borderColor = useColorModeValue("gray.200", "whiteAlpha.300") + const dimColor = useColorModeValue("gray.600", "gray.400") + + const searchQuery = useQuery({ + queryKey: ["globalSearch", normalizedQuery], + queryFn: () => SearchService.search({ q: normalizedQuery }), + enabled: hasQuery, + staleTime: 30_000, + }) + + const results = useMemo(() => { + if (!hasQuery) { + return [] + } + + const pageResults: SearchResult[] = [ + { + id: "page-dashboard", + title: "Dashboard", + description: "Overview and account summary", + category: "Page", + path: "/", + }, + { + id: "page-items", + title: "Items", + description: "Browse and manage item records", + category: "Page", + path: "/items", + }, + { + id: "page-settings", + title: "User Settings", + description: "Profile, password, appearance, and account controls", + category: "Page", + path: "/settings", + }, + ] + + if (currentUser?.is_superuser) { + pageResults.push({ + id: "page-admin", + title: "Admin", + description: "Manage users, roles, and account status", + category: "Page", + path: "/admin", + }) + } + + const matchingPages = pageResults.filter((page) => + matchesQuery(normalizedQuery, [page.title, page.description, page.path]), + ) + + const recordResults = + searchQuery.data?.data.map((result) => ({ + id: result.id, + title: result.title, + description: result.description || result.category, + category: result.category as SearchResult["category"], + path: result.path, + })) ?? [] + + return [...matchingPages, ...recordResults].slice(0, 8) + }, [ + currentUser?.is_superuser, + hasQuery, + normalizedQuery, + searchQuery.data?.data, + ]) + + const isLoading = hasQuery && searchQuery.isFetching + const showResults = isFocused && hasQuery + + return ( + + + + + + setQuery(event.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => { + window.setTimeout(() => setIsFocused(false), 120) + }} + type="search" + placeholder="Search pages, items, users" + fontSize={{ base: "sm", md: "inherit" }} + borderRadius="8px" + aria-label="Global search" + /> + + + {showResults && ( + + {results.map((result) => ( + { + setQuery("") + setIsFocused(false) + }} + > + + + {result.title} + + + {result.category} + + + + {result.description} + + + ))} + + {results.length === 0 && ( + + {isLoading && } + + {isLoading ? "Searching..." : "No matching results"} + + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..5168ad4 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -3,6 +3,7 @@ import { FaPlus } from "react-icons/fa" import AddUser from "../Admin/AddUser" import AddItem from "../Items/AddItem" +import GlobalSearch from "./GlobalSearch" interface NavbarProps { type: string @@ -14,17 +15,12 @@ const Navbar = ({ type }: NavbarProps) => { return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + +