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"])
67 changes: 67 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from typing import Annotated, Any

from fastapi import APIRouter, Query
from sqlalchemy import or_
from sqlmodel import select

from app.api.deps import CurrentUser, SessionDep
from app.models import Item, SearchResult, SearchResultsPublic, User

router = APIRouter()


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

pattern = f"%{query}%"
item_statement = (
select(Item)
.where(or_(Item.title.ilike(pattern), Item.description.ilike(pattern)))
.order_by(Item.id)
.limit(limit)
)
if not current_user.is_superuser:
item_statement = item_statement.where(Item.owner_id == current_user.id)

results: list[SearchResult] = [
SearchResult(
resource_type="item",
id=item.id or 0,
title=item.title,
description=item.description,
path="/items",
)
for item in session.exec(item_statement).all()
]

if current_user.is_superuser and len(results) < limit:
remaining = limit - len(results)
user_statement = (
select(User)
.where(or_(User.email.ilike(pattern), User.full_name.ilike(pattern)))
.order_by(User.id)
.limit(remaining)
)
results.extend(
SearchResult(
resource_type="user",
id=user.id or 0,
title=user.full_name or user.email,
description=user.email,
path="/admin",
)
for user in session.exec(user_statement).all()
)

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 SearchResult(SQLModel):
resource_type: str
id: int
title: str
description: str | None = None
path: str


class SearchResultsPublic(SQLModel):
data: list[SearchResult]
count: int


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


def test_search_returns_current_user_items_only(
client: TestClient, db: Session
) -> None:
password = random_lower_string()
user = crud.create_user(
session=db, user_create=UserCreate(email=random_email(), password=password)
)
other_user = crud.create_user(
session=db,
user_create=UserCreate(email=random_email(), password=random_lower_string()),
)
assert user.id is not None
assert other_user.id is not None

own_item = crud.create_item(
session=db,
item_in=ItemCreate(title="Needle project", description="Public note"),
owner_id=user.id,
)
crud.create_item(
session=db,
item_in=ItemCreate(title="Needle secret", description="Hidden note"),
owner_id=other_user.id,
)
headers = user_authentication_headers(client=client, email=user.email, password=password)

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

assert response.status_code == 200
content = response.json()
assert content["count"] == 1
assert content["data"][0]["resource_type"] == "item"
assert content["data"][0]["id"] == own_item.id


def test_search_superuser_can_find_users(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
user = crud.create_user(
session=db,
user_create=UserCreate(
email=f"search-{random_email()}",
password=random_lower_string(),
full_name="Searchable Person",
),
)

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

assert response.status_code == 200
content = response.json()
assert any(
result["resource_type"] == "user" and result["id"] == user.id
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 = {
resource_type: string;
id: number;
title: string;
description?: string | null;
path: string;
};



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



export type Message = {
message: string;
};
Expand Down
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 @@ -383,6 +383,39 @@ emailTo,

}

export type TDataGlobalSearch = {
limit?: number
q: string

}

export class SearchService {

/**
* Global Search
* Search across resources the current user is allowed to see.
* @returns SearchResultsPublic Successful Response
* @throws ApiError
*/
public static globalSearch(data: TDataGlobalSearch): CancelablePromise<SearchResultsPublic> {
const {
limit = 10,
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
Expand Down Expand Up @@ -521,4 +554,4 @@ id,
});
}

}
}
114 changes: 114 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
Badge,
Box,
Icon,
Input,
InputGroup,
InputLeftElement,
Link,
List,
ListItem,
Spinner,
Text,
} from "@chakra-ui/react"
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { FaSearch } from "react-icons/fa"

import { SearchService } from "../../client"

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")

useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedQuery(query.trim())
}, 250)

return () => window.clearTimeout(timeoutId)
}, [query])

const isSearchable = debouncedQuery.length >= 2
const { data, isFetching } = useQuery({
queryKey: ["globalSearch", debouncedQuery],
queryFn: () => SearchService.globalSearch({ q: debouncedQuery, limit: 8 }),
enabled: isSearchable,
})

const showResults = query.trim().length >= 2
const results = data?.data ?? []

return (
<Box position="relative" w={{ base: "100%", md: "sm" }}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="search"
placeholder="Search"
value={query}
onChange={(event) => setQuery(event.target.value)}
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
/>
</InputGroup>

{showResults && (
<Box
position="absolute"
top="calc(100% + 8px)"
left={0}
right={0}
bg="white"
borderWidth="1px"
borderColor="ui.light"
borderRadius="8px"
boxShadow="md"
zIndex={10}
overflow="hidden"
>
{isFetching ? (
<Box p={4} textAlign="center">
<Spinner size="sm" />
</Box>
) : results.length > 0 ? (
<List>
{results.map((result) => (
<ListItem key={`${result.resource_type}-${result.id}`}>
<Link
href={result.path}
display="block"
px={4}
py={3}
_hover={{ bg: "ui.light", textDecoration: "none" }}
onClick={() => setQuery("")}
>
<Badge mr={2} textTransform="capitalize">
{result.resource_type}
</Badge>
<Text as="span" fontWeight="medium">
{result.title}
</Text>
{result.description && (
<Text color="ui.dim" fontSize="sm" noOfLines={1}>
{result.description}
</Text>
)}
</Link>
</ListItem>
))}
</List>
) : (
<Text color="ui.dim" px={4} py={3}>
No results
</Text>
)}
</Box>
)}
</Box>
)
}

export default GlobalSearch
11 changes: 3 additions & 8 deletions frontend/src/components/Common/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,14 +15,8 @@ const Navbar = ({ type }: NavbarProps) => {

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" }}>
<GlobalSearch />
<Button
variant="primary"
gap={1}
Expand Down