From 87bd706962d61a93b77c61ffbdfba92a10d6dbdf Mon Sep 17 00:00:00 2001 From: Groot89 Date: Wed, 20 May 2026 11:35:37 +0530 Subject: [PATCH] Implement global search --- .../src/components/Common/GlobalSearch.tsx | 240 ++++++++++++++++++ frontend/src/components/Common/Navbar.tsx | 51 +++- frontend/src/components/Common/Sidebar.tsx | 3 + frontend/src/routes/_layout/admin.tsx | 113 ++++++--- frontend/src/routes/_layout/items.tsx | 79 ++++-- 5 files changed, 425 insertions(+), 61 deletions(-) create mode 100644 frontend/src/components/Common/GlobalSearch.tsx diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..11abb85 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -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(["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(() => { + 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 ( + + + + + + { + setQuery(event.target.value) + setIsOpen(true) + }} + onFocus={() => setIsOpen(true)} + onBlur={() => window.setTimeout(() => setIsOpen(false), 120)} + placeholder="Search" + borderRadius="8px" + bg={menuBg} + /> + + + {isOpen && normalizedQuery && ( + + {isLoading ? ( + + + + ) : results.length > 0 ? ( + + {results.map((result) => ( + event.preventDefault()} + onClick={() => selectResult(result)} + > + + {result.type} + + + {result.label} + + + {result.meta} + + + ))} + + ) : ( + + No results found + + )} + + )} + + ) +} + +export default GlobalSearch diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..996c667 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -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 ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + + {onSearchChange && ( + + + + + onSearchChange(event.target.value)} + placeholder={searchPlaceholder ?? `Search ${type.toLowerCase()}s`} + fontSize={{ base: "sm", md: "inherit" }} + borderRadius="8px" + /> + + )}