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"])
84 changes: 84 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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, SearchResult, SearchResultsPublic, User

router = APIRouter()


@router.get("/", response_model=SearchResultsPublic)
def search(
session: SessionDep,
current_user: CurrentUser,
q: str = Query(min_length=1, max_length=100),
limit: int = Query(default=10, ge=1, le=50),
) -> Any:
"""
Search across resources visible to the current user.
"""
query = q.strip().lower()
if not query:
return SearchResultsPublic(data=[], count=0)

results: list[SearchResult] = []
item_limit = limit if not current_user.is_superuser else max(1, limit // 2)
user_limit = limit - item_limit

item_filters = [
or_(
func.lower(Item.title).contains(query),
func.lower(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(item_limit)
for item in session.exec(item_statement):
results.append(
SearchResult(
type="item",
id=item.id,
title=item.title,
description=item.description,
url="/items",
)
)

if current_user.is_superuser:
user_statement = (
select(User)
.where(
or_(
func.lower(User.email).contains(query),
func.lower(User.full_name).contains(query),
)
)
.limit(user_limit)
)
users = session.exec(user_statement)
else:
users = [current_user] if _matches_user(current_user, query) else []

for user in users:
results.append(
SearchResult(
type="user",
id=user.id,
title=user.full_name or user.email,
description=user.email,
url="/admin" if current_user.is_superuser else "/settings",
)
)

return SearchResultsPublic(data=results[:limit], count=len(results))


def _matches_user(user: User, query: str) -> bool:
return query in user.email.lower() or (
user.full_name is not None and query in user.full_name.lower()
)
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 SearchResultsPublic(SQLModel):
data: list[SearchResult]
count: int


# Generic message
class Message(SQLModel):
message: str
Expand Down
37 changes: 37 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from fastapi.testclient import TestClient
from sqlmodel import Session

from app.core.config import settings
from app.tests.utils.item import create_random_item


def test_search_items_for_superuser(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db, title="Searchable bounty task")

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

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


def test_search_requires_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 == 422
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 SearchResult = {
type: string;
id: number;
title: string;
description?: string | null;
url: string;
};



export type SearchResultsPublic = {
data: Array<SearchResult>;
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 resources visible to the current user.
* @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`,
},
});
}

}
106 changes: 96 additions & 10 deletions frontend/src/components/Common/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
import { FaPlus } from "react-icons/fa"
import {
Badge,
Box,
Button,
Flex,
Icon,
Input,
InputGroup,
InputLeftElement,
LinkBox,
LinkOverlay,
Spinner,
Text,
useDisclosure,
} from "@chakra-ui/react"
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { useState } from "react"
import { FaPlus, FaSearch } from "react-icons/fa"

import { SearchService } from "../../client"
import AddUser from "../Admin/AddUser"
import AddItem from "../Items/AddItem"

Expand All @@ -11,17 +29,85 @@ interface NavbarProps {
const Navbar = ({ type }: NavbarProps) => {
const addUserModal = useDisclosure()
const addItemModal = useDisclosure()
const [query, setQuery] = useState("")
const trimmedQuery = query.trim()

const { data: searchResults, isFetching } = useQuery({
queryKey: ["global-search", trimmedQuery],
queryFn: () => SearchService.search({ q: trimmedQuery }),
enabled: trimmedQuery.length > 0,
})

return (
<>
<Flex py={8} gap={4}>
{/* TODO: Complete search functionality */}
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
<InputLeftElement pointerEvents='none'>
<Icon as={FaSearch} color='ui.dim' />
</InputLeftElement>
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
</InputGroup> */}
<Flex py={8} gap={4} direction={{ base: "column", md: "row" }}>
<Box position="relative" w={{ base: "100%", md: "sm" }}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
/>
</InputGroup>
{trimmedQuery.length > 0 && (
<Box
position="absolute"
zIndex={10}
mt={2}
w="100%"
bg="white"
borderWidth="1px"
borderColor="gray.200"
borderRadius="8px"
boxShadow="lg"
overflow="hidden"
>
{isFetching ? (
<Flex align="center" gap={2} px={4} py={3}>
<Spinner size="sm" />
<Text fontSize="sm">Searching</Text>
</Flex>
) : searchResults?.data.length ? (
searchResults.data.map((result) => (
<LinkBox
key={`${result.type}-${result.id}`}
px={4}
py={3}
borderBottomWidth="1px"
borderColor="gray.100"
_hover={{ bg: "gray.50" }}
>
<Flex align="center" justify="space-between" gap={3}>
<Box minW={0}>
<LinkOverlay as={Link} to={result.url}>
<Text fontWeight="medium" noOfLines={1}>
{result.title}
</Text>
</LinkOverlay>
{result.description && (
<Text color="ui.dim" fontSize="sm" noOfLines={1}>
{result.description}
</Text>
)}
</Box>
<Badge>{result.type}</Badge>
</Flex>
</LinkBox>
))
) : (
<Text color="ui.dim" fontSize="sm" px={4} py={3}>
No results
</Text>
)}
</Box>
)}
</Box>
<Button
variant="primary"
gap={1}
Expand Down