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

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

type SearchResult = {
id: string
title: string
subtitle: string
resource: "Item" | "User"
to: "/items" | "/admin"
icon: IconType
}

const matchesSearch = (
searchTerm: string,
values: Array<string | number | null | undefined>,
) =>
values.some((value) => value?.toString().toLowerCase().includes(searchTerm))

const buildItemResult = (item: ItemPublic): SearchResult => ({
id: `item-${item.id}`,
title: item.title,
subtitle: item.description || `Item #${item.id}`,
resource: "Item",
to: "/items",
icon: FiBriefcase,
})

const buildUserResult = (user: UserPublic): SearchResult => ({
id: `user-${user.id}`,
title: user.full_name || user.email,
subtitle: `${user.email} - ${user.is_superuser ? "Superuser" : "User"} - ${
user.is_active ? "Active" : "Inactive"
}`,
resource: "User",
to: "/admin",
icon: FiUser,
})

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const [isOpen, setIsOpen] = useState(false)
const searchRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])

const panelBg = useColorModeValue("white", "ui.dark")
const borderColor = useColorModeValue("gray.200", "gray.600")
const hoverBg = useColorModeValue("gray.50", "gray.700")

useOutsideClick({
ref: searchRef,
handler: () => setIsOpen(false),
})

const { data: items, isFetching: isFetchingItems } = useQuery({
queryKey: ["items"],
queryFn: () => ItemsService.readItems({}),
staleTime: 30_000,
})

const { data: users, isFetching: isFetchingUsers } = useQuery({
queryKey: ["users"],
queryFn: () => UsersService.readUsers({}),
enabled: Boolean(currentUser?.is_superuser),
retry: false,
staleTime: 30_000,
})

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

const results = useMemo(() => {
if (!searchTerm) {
return []
}

const itemResults =
items?.data
.filter((item) =>
matchesSearch(searchTerm, [item.id, item.title, item.description]),
)
.map(buildItemResult) || []

const userResults =
currentUser?.is_superuser && users?.data
? users.data
.filter((user) =>
matchesSearch(searchTerm, [
user.id,
user.email,
user.full_name,
user.is_superuser ? "superuser" : "user",
user.is_active ? "active" : "inactive",
]),
)
.map(buildUserResult)
: []

return [...itemResults, ...userResults].slice(0, 8)
}, [currentUser?.is_superuser, items?.data, searchTerm, users?.data])

const isFetching =
isFetchingItems || (Boolean(currentUser?.is_superuser) && isFetchingUsers)
const shouldShowPanel = isOpen && Boolean(searchTerm)

const closeSearch = () => {
setQuery("")
setIsOpen(false)
}

return (
<Box ref={searchRef} position="relative" w={{ base: "100%", md: "sm" }}>
<InputGroup w="full">
<InputLeftElement pointerEvents="none">
<Icon as={FiSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="text"
value={query}
placeholder="Search all resources"
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
onChange={(event) => {
setQuery(event.target.value)
setIsOpen(true)
}}
onFocus={() => {
if (query.trim()) {
setIsOpen(true)
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false)
}
}}
/>
</InputGroup>

{shouldShowPanel && (
<Box
bg={panelBg}
borderColor={borderColor}
borderRadius="8px"
borderWidth="1px"
boxShadow="lg"
left={0}
maxH="320px"
mt={2}
overflowY="auto"
position="absolute"
right={0}
zIndex={20}
>
{results.length > 0 ? (
results.map((result) => (
<Flex
key={result.id}
as={Link}
to={result.to}
align="center"
gap={3}
px={3}
py={2}
_hover={{ bg: hoverBg }}
onClick={closeSearch}
>
<Icon as={result.icon} color="ui.main" />
<Box flex="1" minW={0}>
<Text fontWeight="medium" noOfLines={1}>
{result.title}
</Text>
<Text color="ui.dim" fontSize="sm" noOfLines={1}>
{result.subtitle}
</Text>
</Box>
<Badge>{result.resource}</Badge>
</Flex>
))
) : (
<Flex align="center" gap={2} px={3} py={3}>
{isFetching && <Spinner color="ui.main" size="sm" />}
<Text color="ui.dim" fontSize="sm">
{isFetching
? "Searching resources..."
: "No matching resources"}
</Text>
</Flex>
)}
</Box>
)}
</Box>
)
}

export default GlobalSearch
18 changes: 10 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,17 +15,18 @@ 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}
align={{ base: "stretch", sm: "center" }}
direction={{ base: "column", sm: "row" }}
justify="space-between"
>
<GlobalSearch />
<Button
variant="primary"
gap={1}
alignSelf={{ base: "stretch", sm: "auto" }}
fontSize={{ base: "sm", md: "inherit" }}
onClick={type === "User" ? addUserModal.onOpen : addItemModal.onOpen}
>
Expand Down