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

from fastapi import APIRouter
from sqlmodel import col, or_, 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,
query: str,
limit: int = 10,
) -> Any:
"""
Search visible users and items.
"""
term = query.strip()
if not term:
return SearchResults(data=[], count=0)

like_term = f"%{term}%"
bounded_limit = min(max(limit, 1), 25)

item_statement = select(Item).where(
or_(
col(Item.title).ilike(like_term),
col(Item.description).ilike(like_term),
)
)
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 = [
SearchResult(
id=item.id,
type="item",
title=item.title,
subtitle=item.description,
url="/items",
)
for item in items
if item.id is not None
]

remaining = bounded_limit - len(results)
if remaining > 0:
if current_user.is_superuser:
user_statement = select(User).where(
or_(
col(User.email).ilike(like_term),
col(User.full_name).ilike(like_term),
)
)
users = session.exec(user_statement.limit(remaining)).all()
else:
user_matches = term.lower() in current_user.email.lower()
if current_user.full_name:
user_matches = user_matches or term.lower() in current_user.full_name.lower()
users = [current_user] if user_matches else []

results.extend(
SearchResult(
id=user.id,
type="user",
title=user.full_name or user.email,
subtitle=user.email,
url="/admin" if current_user.is_superuser else "/settings",
)
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):
id: int
type: str
title: str
subtitle: str | None = None
url: str


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


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

from app import crud
from app.core.config import settings
from app.models import ItemCreate, UserUpdate
from app.tests.utils.user import create_random_user, user_authentication_headers
from app.tests.utils.utils import random_lower_string


def test_search_returns_visible_items_for_current_user(
client: TestClient, db: Session
) -> None:
password = random_lower_string()
user = create_random_user(db)
user_in = UserUpdate(password=password)
user = crud.update_user(session=db, db_user=user, user_in=user_in)
assert user.id

item = crud.create_item(
session=db,
item_in=ItemCreate(title="Alpha invoice", description="Quarterly report"),
owner_id=user.id,
)
other_user = create_random_user(db)
assert other_user.id
crud.create_item(
session=db,
item_in=ItemCreate(title="Alpha private", description="Other owner"),
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={"query": "Alpha"},
)

assert response.status_code == 200
data = response.json()["data"]
assert [result["id"] for result in data if result["type"] == "item"] == [item.id]


def test_search_superuser_can_search_users(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
user = create_random_user(db)
user.full_name = "Needle Person"
db.add(user)
db.commit()

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

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



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



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



export type NewPassword = {
token: string;
new_password: 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,SearchResults,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models';

export type TDataLoginAccessToken = {
formData: Body_login_login_access_token
Expand Down Expand Up @@ -383,6 +383,39 @@ emailTo,

}

export type TDataSearch = {
limit?: number
query: string

}

export class SearchService {

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

}

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

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

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const trimmedQuery = query.trim()
const shouldSearch = trimmedQuery.length >= 2
const borderColor = useColorModeValue("gray.200", "gray.700")
const bgColor = useColorModeValue("white", "ui.darkSlate")

const { data, isFetching } = useQuery({
queryKey: ["global-search", trimmedQuery],
queryFn: () => SearchService.search({ query: trimmedQuery, limit: 8 }),
enabled: shouldSearch,
})

const results = data?.data ?? []

return (
<Box
position="fixed"
top={4}
right={{ base: 4, md: 16 }}
zIndex={2}
w={{ base: "calc(100% - 2rem)", md: "360px" }}
>
<InputGroup>
<InputLeftElement pointerEvents="none">
{isFetching ? (
<Spinner size="sm" color="ui.dim" />
) : (
<FiSearch color="var(--chakra-colors-ui-dim)" />
)}
</InputLeftElement>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
bg={bgColor}
borderColor={borderColor}
shadow="sm"
/>
</InputGroup>

{shouldSearch && (
<List
mt={2}
bg={bgColor}
border="1px solid"
borderColor={borderColor}
borderRadius="md"
shadow="lg"
overflow="hidden"
>
{results.length ? (
results.map((result) => (
<ListItem
key={`${result.type}-${result.id}`}
as={Link}
to={result.url}
display="block"
p={3}
_hover={{ bg: "ui.secondary" }}
onClick={() => setQuery("")}
>
<Box display="flex" gap={2} alignItems="center">
<Text fontWeight="medium" noOfLines={1}>
{result.title}
</Text>
<Badge>{result.type}</Badge>
</Box>
{result.subtitle && (
<Text fontSize="sm" color="ui.dim" noOfLines={1}>
{result.subtitle}
</Text>
)}
</ListItem>
))
) : (
<ListItem p={3}>
<Text color="ui.dim">No results found</Text>
</ListItem>
)}
</List>
)}
</Box>
)
}

export default GlobalSearch
2 changes: 2 additions & 0 deletions frontend/src/routes/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Flex, Spinner } from "@chakra-ui/react"
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"

import GlobalSearch from "../components/Common/GlobalSearch"
import Sidebar from "../components/Common/Sidebar"
import UserMenu from "../components/Common/UserMenu"
import useAuth, { isLoggedIn } from "../hooks/useAuth"
Expand Down Expand Up @@ -29,6 +30,7 @@ function Layout() {
) : (
<Outlet />
)}
<GlobalSearch />
<UserMenu />
</Flex>
)
Expand Down