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
240 changes: 240 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import {
Badge,
Box,
Icon,
Input,
InputGroup,
InputLeftElement,
List,
ListItem,
Spinner,
Text,
useColorModeValue,
} from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { FiSearch } from "react-icons/fi"

import { ItemsService, type UserPublic, UsersService } from "../../client"

type SearchResult = {
key: string
label: string
meta: string
type: string
onSelect: () => void
}

const includesQuery = (
value: string | number | null | undefined,
query: string,
) =>
String(value ?? "")
.toLowerCase()
.includes(query)

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const menuBg = useColorModeValue("white", "gray.800")
const hoverBg = useColorModeValue("gray.50", "gray.700")
const borderColor = useColorModeValue("gray.200", "gray.700")

const itemsQuery = useQuery({
queryKey: ["items"],
queryFn: () => ItemsService.readItems({}),
})

const usersQuery = useQuery({
queryKey: ["users"],
queryFn: () => UsersService.readUsers({}),
enabled: !!currentUser?.is_superuser,
})

const normalizedQuery = query.trim().toLowerCase()

const results = useMemo<SearchResult[]>(() => {
if (!normalizedQuery) {
return []
}

const pageResults: SearchResult[] = [
{
key: "page-dashboard",
label: "Dashboard",
meta: "Page",
type: "Page",
onSelect: () => navigate({ to: "/" }),
},
{
key: "page-items",
label: "Items",
meta: "Page",
type: "Page",
onSelect: () => navigate({ to: "/items", search: { search: "" } }),
},
{
key: "page-settings",
label: "User Settings",
meta: "Page",
type: "Page",
onSelect: () => navigate({ to: "/settings" }),
},
...(currentUser?.is_superuser
? [
{
key: "page-admin",
label: "Admin",
meta: "Page",
type: "Page",
onSelect: () =>
navigate({ to: "/admin", search: { search: "" } }),
},
]
: []),
].filter(
(result) =>
includesQuery(result.label, normalizedQuery) ||
includesQuery(result.meta, normalizedQuery),
)

const itemResults =
itemsQuery.data?.data
.filter(
(item) =>
includesQuery(item.id, normalizedQuery) ||
includesQuery(item.title, normalizedQuery) ||
includesQuery(item.description, normalizedQuery),
)
.slice(0, 4)
.map((item) => ({
key: `item-${item.id}`,
label: item.title,
meta: item.description || `Item #${item.id}`,
type: "Item",
onSelect: () =>
navigate({
to: "/items",
search: { search: item.title || String(item.id) },
}),
})) ?? []

const userResults =
usersQuery.data?.data
.filter(
(user) =>
includesQuery(user.id, normalizedQuery) ||
includesQuery(user.full_name, normalizedQuery) ||
includesQuery(user.email, normalizedQuery),
)
.slice(0, 4)
.map((user) => ({
key: `user-${user.id}`,
label: user.full_name || user.email,
meta: user.email,
type: "User",
onSelect: () =>
navigate({
to: "/admin",
search: { search: user.full_name || user.email },
}),
})) ?? []

return [...pageResults, ...itemResults, ...userResults].slice(0, 8)
}, [
currentUser?.is_superuser,
itemsQuery.data?.data,
navigate,
normalizedQuery,
usersQuery.data?.data,
])

const isLoading =
itemsQuery.isLoading || (currentUser?.is_superuser && usersQuery.isLoading)

const selectResult = (result: SearchResult) => {
result.onSelect()
setQuery("")
setIsOpen(false)
}

return (
<Box position="relative" w="100%" px={2} mb={4}>
<InputGroup size="sm">
<InputLeftElement pointerEvents="none">
<Icon as={FiSearch} color="ui.dim" />
</InputLeftElement>
<Input
value={query}
onChange={(event) => {
setQuery(event.target.value)
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onBlur={() => window.setTimeout(() => setIsOpen(false), 120)}
placeholder="Search"
borderRadius="8px"
bg={menuBg}
/>
</InputGroup>

{isOpen && normalizedQuery && (
<Box
position="absolute"
left={2}
right={2}
top="calc(100% + 6px)"
zIndex={20}
bg={menuBg}
border="1px solid"
borderColor={borderColor}
borderRadius="8px"
boxShadow="lg"
overflow="hidden"
>
{isLoading ? (
<Box p={3} textAlign="center">
<Spinner size="sm" />
</Box>
) : results.length > 0 ? (
<List>
{results.map((result) => (
<ListItem
as="button"
key={result.key}
w="100%"
textAlign="left"
px={3}
py={2}
_hover={{ bg: hoverBg }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectResult(result)}
>
<Badge size="sm" mr={2}>
{result.type}
</Badge>
<Text as="span" fontWeight="medium" noOfLines={1}>
{result.label}
</Text>
<Text fontSize="xs" color="ui.dim" noOfLines={1}>
{result.meta}
</Text>
</ListItem>
))}
</List>
) : (
<Text p={3} fontSize="sm" color="ui.dim">
No results found
</Text>
)}
</Box>
)}
</Box>
)
}

export default GlobalSearch
51 changes: 40 additions & 11 deletions frontend/src/components/Common/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,56 @@
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
import { FaPlus } from "react-icons/fa"
import {
Button,
Flex,
Icon,
Input,
InputGroup,
InputLeftElement,
useDisclosure,
} from "@chakra-ui/react"
import { FaPlus, FaSearch } from "react-icons/fa"

import AddUser from "../Admin/AddUser"
import AddItem from "../Items/AddItem"

interface NavbarProps {
type: string
searchValue?: string
searchPlaceholder?: string
onSearchChange?: (value: string) => void
}

const Navbar = ({ type }: NavbarProps) => {
const Navbar = ({
type,
searchValue = "",
searchPlaceholder,
onSearchChange,
}: NavbarProps) => {
const addUserModal = useDisclosure()
const addItemModal = useDisclosure()

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={{ base: "stretch", sm: "center" }}
direction={{ base: "column", sm: "row" }}
>
{onSearchChange && (
<InputGroup w={{ base: "100%", md: "320px" }}>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="text"
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder ?? `Search ${type.toLowerCase()}s`}
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
/>
</InputGroup>
)}
<Button
variant="primary"
gap={1}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/Common/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { FiLogOut, FiMenu } from "react-icons/fi"
import Logo from "../../assets/images/fastapi-logo.svg"
import type { UserPublic } from "../../client"
import useAuth from "../../hooks/useAuth"
import GlobalSearch from "./GlobalSearch"
import SidebarItems from "./SidebarItems"

const Sidebar = () => {
Expand Down Expand Up @@ -53,6 +54,7 @@ const Sidebar = () => {
<Flex flexDir="column" justify="space-between">
<Box>
<Image src={Logo} alt="logo" p={6} />
<GlobalSearch />
<SidebarItems onClose={onClose} />
<Flex
as="button"
Expand Down Expand Up @@ -94,6 +96,7 @@ const Sidebar = () => {
>
<Box>
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
<GlobalSearch />
<SidebarItems />
</Box>
{currentUser?.email && (
Expand Down
Loading