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"])
75 changes: 75 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -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))
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 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
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 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}
18 changes: 17 additions & 1 deletion 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 SearchResultPublic = {
id: number;
type: string;
title: string;
subtitle?: string | null;
path: string;
};



export type SearchResultsPublic = {
data: Array<SearchResultPublic>;
count: number;
};



export type Message = {
message: string;
};
Expand Down Expand Up @@ -129,4 +146,3 @@ export type ValidationError = {
msg: string;
type: string;
};

37 changes: 35 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,SearchResultsPublic } from './models';

export type TDataLoginAccessToken = {
formData: Body_login_login_access_token
Expand Down Expand Up @@ -521,4 +521,37 @@ id,
});
}

}
}

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<SearchResultsPublic> {
const {
limit = 10,
q,
} = data;
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/search/',
query: {
q, limit
},
errors: {
422: `Validation Error`,
},
});
}

}
126 changes: 126 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box as="form" onSubmit={handleSubmit} position="relative" px={2} pb={4}>
<InputGroup size="sm">
<InputLeftElement pointerEvents="none">
{isFetching ? <Spinner size="xs" /> : <FiSearch />}
</InputLeftElement>
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Search"
borderRadius="8px"
bg={inputBg}
/>
</InputGroup>

{query.length >= 2 && (
<Box
position="absolute"
top="38px"
left={2}
right={2}
zIndex={10}
bg={resultBg}
border="1px solid"
borderColor={borderColor}
borderRadius="8px"
boxShadow="lg"
overflow="hidden"
>
{results.length > 0 ? (
results.map((result) => (
<Flex
as="button"
type="button"
key={`${result.type}-${result.id}`}
onClick={() => openResult(result)}
w="full"
textAlign="left"
px={3}
py={2}
gap={2}
align="center"
_hover={{ bg: hoverBg }}
>
<Badge
colorScheme={result.type === "user" ? "purple" : "green"}
>
{result.type}
</Badge>
<Box minW={0}>
<Text fontWeight="medium" noOfLines={1}>
{result.title}
</Text>
{result.subtitle && (
<Text fontSize="xs" color="ui.dim" noOfLines={1}>
{result.subtitle}
</Text>
)}
</Box>
</Flex>
))
) : (
<Text px={3} py={2} fontSize="sm" color="ui.dim">
No results
</Text>
)}
</Box>
)}
</Box>
)
}

export default GlobalSearch
Loading