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 sqlmodel import col, or_, 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, limit: int = 10
) -> Any:
"""
Search across resources visible to the current user.
"""
query = q.strip()
if not query:
return SearchResultsPublic(data=[], count=0)

like_query = f"%{query}%"
max_results = max(1, min(limit, 50))
results: list[SearchResult] = []

item_statement = select(Item).where(
or_(
col(Item.title).ilike(like_query),
col(Item.description).ilike(like_query),
)
)
if not current_user.is_superuser:
item_statement = item_statement.where(Item.owner_id == current_user.id)

items = session.exec(item_statement.limit(max_results)).all()
for item in items:
results.append(
SearchResult(
id=item.id or 0,
type="item",
title=item.title,
subtitle=item.description,
path="/items",
)
)

if current_user.is_superuser and len(results) < max_results:
remaining = max_results - len(results)
user_statement = select(User).where(
or_(
col(User.email).ilike(like_query),
col(User.full_name).ilike(like_query),
)
)
users = session.exec(user_statement.limit(remaining)).all()
for user in users:
results.append(
SearchResult(
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 SearchResult(SQLModel):
id: int
type: str
title: str
subtitle: str | None = None
path: str


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


# Generic message
class Message(SQLModel):
message: str
Expand Down
90 changes: 90 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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_matching_items_for_current_user(
client: TestClient, db: Session
) -> None:
email = random_email()
password = random_lower_string()
user = crud.create_user(
session=db, user_create=UserCreate(email=email, password=password)
)
assert user.id is not None
item = crud.create_item(
session=db,
item_in=ItemCreate(title="Quarterly roadmap", description="Planning notes"),
owner_id=user.id,
)
headers = user_authentication_headers(client=client, email=email, password=password)

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

assert response.status_code == 200
content = response.json()
assert content["count"] == 1
assert content["data"][0]["id"] == item.id
assert content["data"][0]["type"] == "item"
assert content["data"][0]["path"] == "/items"


def test_search_hides_other_users_items_for_normal_users(
client: TestClient, db: Session
) -> None:
owner = crud.create_user(
session=db,
user_create=UserCreate(email=random_email(), password=random_lower_string()),
)
assert owner.id is not None
crud.create_item(
session=db,
item_in=ItemCreate(title="Private launch plan", description=None),
owner_id=owner.id,
)

email = random_email()
password = random_lower_string()
crud.create_user(session=db, user_create=UserCreate(email=email, password=password))
headers = user_authentication_headers(client=client, email=email, password=password)

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

assert response.status_code == 200
assert response.json() == {"data": [], "count": 0}


def test_search_includes_users_for_superusers(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
crud.create_user(
session=db,
user_create=UserCreate(
email="searchable-user@example.com",
password=random_lower_string(),
full_name="Searchable User",
),
)

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["type"] == "user" for result in content["data"])
14 changes: 13 additions & 1 deletion frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ export type ItemsPublic = {
count: number;
};

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

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



export type Message = {
Expand Down Expand Up @@ -129,4 +142,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 @@ -383,6 +383,39 @@ emailTo,

}

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`,
},
});
}

}

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

}
}
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,
List,
ListItem,
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 [searchTerm, setSearchTerm] = useState("")
const query = searchTerm.trim()
const showResults = query.length >= 2
const { data, isFetching } = useQuery({
queryKey: ["global-search", query],
queryFn: () => SearchService.search({ q: query }),
enabled: showResults,
})

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} align="flex-start" flexWrap="wrap">
<Box position="relative" w={{ base: "100%", md: "320px" }}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="text"
placeholder="Search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
/>
</InputGroup>
{showResults && (
<Box
position="absolute"
zIndex={10}
mt={2}
w="100%"
bg="white"
border="1px solid"
borderColor="ui.secondary"
borderRadius="8px"
boxShadow="lg"
overflow="hidden"
>
{isFetching ? (
<Flex p={4} justify="center">
<Spinner size="sm" />
</Flex>
) : data?.data.length ? (
<List>
{data.data.map((result) => (
<ListItem key={`${result.type}-${result.id}`}>
<Flex
as={Link}
to={result.path}
p={3}
gap={3}
align="center"
_hover={{ bg: "ui.secondary" }}
onClick={() => setSearchTerm("")}
>
<Badge textTransform="capitalize">{result.type}</Badge>
<Box minW={0}>
<Text fontWeight="medium" noOfLines={1}>
{result.title}
</Text>
{result.subtitle && (
<Text color="ui.dim" fontSize="sm" noOfLines={1}>
{result.subtitle}
</Text>
)}
</Box>
</Flex>
</ListItem>
))}
</List>
) : (
<Text p={4} color="ui.dim">
No results found
</Text>
)}
</Box>
)}
</Box>
<Button
variant="primary"
gap={1}
Expand Down