Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -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"])
68 changes: 68 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from typing import Any

from fastapi import APIRouter
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,
limit: int = 10,
) -> Any:
"""
Search across records visible to the current user.
"""
query = q.strip()
if not query:
return SearchResults(data=[], count=0)

bounded_limit = max(1, min(limit, 25))
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(bounded_limit)).all()
results.extend(
SearchResult(
type="item",
id=item.id,
title=item.title,
description=item.description,
url="/items",
)
for item in items
if item.id is not None
)

if current_user.is_superuser and len(results) < bounded_limit:
remaining = bounded_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(
type="user",
id=user.id,
title=user.full_name or user.email,
description=user.email,
url="/admin",
)
for user in users
if user.id is not None
)

return SearchResults(data=results, count=len(results))
13 changes: 13 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 SearchResults(SQLModel):
data: list[SearchResult]
count: int


# Generic message
class Message(SQLModel):
message: str
Expand Down
80 changes: 80 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -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 ItemCreate, UserCreate
from app.tests.utils.user import create_random_user
from app.tests.utils.utils import random_email


def test_search_returns_current_users_items(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
item = {"title": "Needle Search Item", "description": "Findable text"}
client.post(
f"{settings.API_V1_STR}/items/",
headers=normal_user_token_headers,
json=item,
)

response = client.get(
f"{settings.API_V1_STR}/search/",
headers=normal_user_token_headers,
params={"q": "Needle"},
)

assert response.status_code == 200
content = response.json()
assert content["count"] >= 1
assert any(
result["type"] == "item" and result["title"] == item["title"]
for result in content["data"]
)


def test_search_hides_other_users_items(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
other_user = create_random_user(db)
assert other_user.id is not None
item_in = ItemCreate(
title="Private Search Needle",
description="Should not be visible",
)
crud.create_item(session=db, item_in=item_in, owner_id=other_user.id)

response = client.get(
f"{settings.API_V1_STR}/search/",
headers=normal_user_token_headers,
params={"q": "Private Search Needle"},
)

assert response.status_code == 200
content = response.json()
assert all(result["title"] != item_in.title for result in content["data"])


def test_superuser_search_returns_users(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
email = random_email()
user_in = UserCreate(
email=email,
password="test-password",
full_name="Global Search User",
)
crud.create_user(session=db, user_create=user_in)

response = client.get(
f"{settings.API_V1_STR}/search/",
headers=superuser_token_headers,
params={"q": email},
)

assert response.status_code == 200
content = response.json()
assert any(
result["type"] == "user" and result["title"] == user_in.full_name
for result in content["data"]
)
17 changes: 17 additions & 0 deletions frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ export type ItemsPublic = {



export type SearchResult = {
type: string;
id: number;
title: string;
description?: string | null;
url: string;
};



export type SearchResults = {
data: Array<SearchResult>;
count: number;
};



export type Message = {
message: string;
};
Expand Down
36 changes: 34 additions & 2 deletions frontend/src/client/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -124,6 +124,38 @@ email,

}

export type TDataSearch = {
limit?: number
q: string
}

export class SearchService {

/**
* Search
* Search across records visible to the current user.
* @returns SearchResults Successful Response
* @throws ApiError
*/
public static search(data: TDataSearch): CancelablePromise<SearchResults> {
const {
limit = 10,
q,
} = data;
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/search/',
query: {
q, limit
},
errors: {
422: `Validation Error`,
},
});
}

}

export type TDataReadUsers = {
limit?: number
skip?: number
Expand Down Expand Up @@ -521,4 +553,4 @@ id,
});
}

}
}
102 changes: 102 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
Badge,
Box,
Input,
InputGroup,
InputLeftElement,
Spinner,
Text,
VStack,
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"

interface GlobalSearchProps {
onSelect?: () => void
}

const GlobalSearch = ({ onSelect }: GlobalSearchProps) => {
const [query, setQuery] = useState("")
const trimmedQuery = query.trim()
const resultBg = useColorModeValue("white", "ui.darkSlate")
const resultBorder = useColorModeValue("gray.200", "gray.700")

const { data, isFetching } = useQuery({
queryKey: ["global-search", trimmedQuery],
queryFn: () => SearchService.search({ q: trimmedQuery, limit: 8 }),
enabled: trimmedQuery.length >= 2,
staleTime: 30_000,
})

const results = data?.data ?? []

return (
<Box mb={4}>
<InputGroup size="sm">
<InputLeftElement pointerEvents="none">
{isFetching ? <Spinner size="xs" /> : <FiSearch />}
</InputLeftElement>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
borderRadius="8px"
bg={resultBg}
/>
</InputGroup>

{trimmedQuery.length >= 2 && (
<Box
mt={2}
border="1px solid"
borderColor={resultBorder}
borderRadius="8px"
bg={resultBg}
overflow="hidden"
>
{results.length === 0 && !isFetching ? (
<Text color="ui.dim" fontSize="sm" px={3} py={2}>
No results
</Text>
) : (
<VStack align="stretch" gap={0}>
{results.map((result) => (
<Box
as={Link}
key={`${result.type}-${result.id}`}
to={result.url}
px={3}
py={2}
_hover={{ bg: "ui.secondary" }}
onClick={() => {
setQuery("")
onSelect?.()
}}
>
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{result.title}
</Text>
<Badge size="sm" textTransform="capitalize">
{result.type}
</Badge>
{result.description && (
<Text color="ui.dim" fontSize="xs" noOfLines={1}>
{result.description}
</Text>
)}
</Box>
))}
</VStack>
)}
</Box>
)}
</Box>
)
}

export default GlobalSearch
3 changes: 3 additions & 0 deletions frontend/src/components/Common/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -53,6 +54,7 @@ const Sidebar = () => {
<Flex flexDir="column" justify="space-between">
<Box>
<Image src={Logo} alt="logo" p={6} />
<GlobalSearch onSelect={onClose} />
<SidebarItems onClose={onClose} />
<Flex
as="button"
Expand Down Expand Up @@ -94,6 +96,7 @@ const Sidebar = () => {
>
<Box>
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
<GlobalSearch />
<SidebarItems />
</Box>
{currentUser?.email && (
Expand Down