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
268 changes: 268 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import {
Badge,
Box,
Flex,
Icon,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Spinner,
Text,
useColorModeValue,
} from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import type { KeyboardEvent } from "react"
import { useMemo, useState } from "react"
import { FaSearch } from "react-icons/fa"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"

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

type SearchPath = "/" | "/items" | "/settings" | "/admin"

type SearchResult = {
id: string
title: string
subtitle: string
category: "Page" | "Item" | "User"
path: SearchPath
icon: typeof FiHome
searchableText: string
}

const navigateToPath = (
navigate: ReturnType<typeof useNavigate>,
path: SearchPath,
) => {
switch (path) {
case "/items":
navigate({ to: "/items" })
break
case "/settings":
navigate({ to: "/settings" })
break
case "/admin":
navigate({ to: "/admin" })
break
default:
navigate({ to: "/" })
}
}

const GlobalSearch = () => {
const [query, setQuery] = useState("")
const [isFocused, setIsFocused] = useState(false)
const queryClient = useQueryClient()
const navigate = useNavigate()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])

const bg = useColorModeValue("white", "ui.darkSlate")
const borderColor = useColorModeValue("gray.200", "gray.700")
const hoverBg = useColorModeValue("gray.50", "gray.700")
const mutedColor = useColorModeValue("gray.500", "gray.400")

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

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

const trimmedQuery = query.trim().toLowerCase()
const isLoading = itemsQuery.isLoading || usersQuery.isLoading
const placeholder = currentUser?.is_superuser
? "Search pages, items, users"
: "Search pages and items"

const results = useMemo(() => {
const pages: SearchResult[] = [
{
id: "page-dashboard",
title: "Dashboard",
subtitle: "Overview and account summary",
category: "Page",
path: "/",
icon: FiHome,
searchableText: "dashboard overview account summary home",
},
{
id: "page-items",
title: "Items",
subtitle: "Manage items",
category: "Page",
path: "/items",
icon: FiBriefcase,
searchableText: "items item management title description",
},
{
id: "page-settings",
title: "User Settings",
subtitle: "Profile and password settings",
category: "Page",
path: "/settings",
icon: FiSettings,
searchableText: "settings user profile password appearance account",
},
]

if (currentUser?.is_superuser) {
pages.push({
id: "page-admin",
title: "Admin",
subtitle: "Manage users",
category: "Page",
path: "/admin",
icon: FiUsers,
searchableText: "admin users user management email role status",
})
}

const items: SearchResult[] =
itemsQuery.data?.data.map((item: ItemPublic) => ({
id: `item-${item.id}`,
title: item.title,
subtitle: item.description || `Item #${item.id}`,
category: "Item",
path: "/items",
icon: FiBriefcase,
searchableText: `${item.title} ${item.description ?? ""} ${
item.id
}`.toLowerCase(),
})) ?? []

const users: SearchResult[] =
usersQuery.data?.data.map((user: UserPublic) => ({
id: `user-${user.id}`,
title: user.full_name || user.email,
subtitle: `${user.email} - ${user.is_superuser ? "Superuser" : "User"}`,
category: "User",
path: "/admin",
icon: FiUsers,
searchableText: `${user.full_name ?? ""} ${user.email} ${
user.is_superuser ? "superuser" : "user"
} ${user.is_active ? "active" : "inactive"}`.toLowerCase(),
})) ?? []

if (!trimmedQuery) {
return []
}

return [...pages, ...items, ...users]
.filter((result) => result.searchableText.includes(trimmedQuery))
.slice(0, 8)
}, [
currentUser?.is_superuser,
itemsQuery.data?.data,
trimmedQuery,
usersQuery.data?.data,
])

const showResults = isFocused && trimmedQuery.length > 0

const handleSelect = (result: SearchResult) => {
setQuery("")
setIsFocused(false)
navigateToPath(navigate, result.path)
}

const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && results.length > 0) {
handleSelect(results[0])
}

if (event.key === "Escape") {
setIsFocused(false)
}
}

return (
<Box position="relative" w={{ base: "100%", md: "420px" }}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="search"
value={query}
placeholder={placeholder}
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
/>
{isLoading && (
<InputRightElement>
<Spinner size="sm" color="ui.main" />
</InputRightElement>
)}
</InputGroup>

{showResults && (
<Box
bg={bg}
border="1px solid"
borderColor={borderColor}
borderRadius="8px"
boxShadow="lg"
mt={2}
overflow="hidden"
position="absolute"
w="100%"
zIndex={20}
>
{results.length > 0 ? (
results.map((result) => (
<Flex
as="button"
align="center"
gap={3}
key={result.id}
onClick={() => handleSelect(result)}
px={4}
py={3}
textAlign="left"
type="button"
w="100%"
_hover={{ bg: hoverBg }}
>
<Icon as={result.icon} color="ui.main" flexShrink={0} />
<Box minW={0}>
<Flex align="center" gap={2}>
<Text fontWeight="semibold" noOfLines={1}>
{result.title}
</Text>
<Badge colorScheme="teal" flexShrink={0}>
{result.category}
</Badge>
</Flex>
<Text color={mutedColor} fontSize="sm" noOfLines={1}>
{result.subtitle}
</Text>
</Box>
</Flex>
))
) : (
<Text color={mutedColor} px={4} py={3}>
No results found
</Text>
)}
</Box>
)}
</Box>
)
}

export default GlobalSearch
16 changes: 8 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,13 @@ 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
align={{ base: "stretch", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={4}
py={8}
>
<GlobalSearch />
<Button
variant="primary"
gap={1}
Expand Down