From 7e7d4d01a2dd47a09b6c98e0f47a6faceb8fe466 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 27 Jul 2023 13:05:39 +1000 Subject: [PATCH] Global search input drawer (#5346) * First pass at global search input drawer * Perform debounced query * Build custom search query * Render basic search results * Flesh out more search types * Add TODO * Remove "bold" property * Updates * Improve rendering of search results * Center loader * Implement better "refresh" button * Add alert on error * Add "no results" alert * Fix regex and whole word search --- .../src/components/items/ScanButton.tsx | 3 + src/frontend/src/components/nav/Header.tsx | 19 +- .../src/components/nav/SearchDrawer.tsx | 438 ++++++++++++++++++ 3 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 src/frontend/src/components/nav/SearchDrawer.tsx diff --git a/src/frontend/src/components/items/ScanButton.tsx b/src/frontend/src/components/items/ScanButton.tsx index 65c30caef6..c3fe6d14b5 100644 --- a/src/frontend/src/components/items/ScanButton.tsx +++ b/src/frontend/src/components/items/ScanButton.tsx @@ -3,6 +3,9 @@ import { ActionIcon } from '@mantine/core'; import { openContextModal } from '@mantine/modals'; import { IconQrcode } from '@tabler/icons-react'; +/** + * A button which opens the QR code scanner modal + */ export function ScanButton() { return ( <ActionIcon diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index 0e3fe7fcb9..6b403db455 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -1,5 +1,6 @@ -import { Container, Group, Tabs } from '@mantine/core'; +import { ActionIcon, Container, Group, Tabs } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +import { IconSearch } from '@tabler/icons-react'; import { useNavigate, useParams } from 'react-router-dom'; import { navTabs as mainNavTabs } from '../../defaults/links'; @@ -8,22 +9,32 @@ import { ScanButton } from '../items/ScanButton'; import { MainMenu } from './MainMenu'; import { NavHoverMenu } from './NavHoverMenu'; import { NavigationDrawer } from './NavigationDrawer'; +import { SearchDrawer } from './SearchDrawer'; export function Header() { const { classes } = InvenTreeStyle(); - const [drawerOpened, { open: openDrawer, close }] = useDisclosure(false); + const [navDrawerOpened, { open: openNavDrawer, close: closeNavDrawer }] = + useDisclosure(false); + const [ + searchDrawerOpened, + { open: openSearchDrawer, close: closeSearchDrawer } + ] = useDisclosure(false); return ( <div className={classes.layoutHeader}> - <NavigationDrawer opened={drawerOpened} close={close} /> + <SearchDrawer opened={searchDrawerOpened} onClose={closeSearchDrawer} /> + <NavigationDrawer opened={navDrawerOpened} close={closeNavDrawer} /> <Container className={classes.layoutHeaderSection} size={'xl'}> <Group position="apart"> <Group> - <NavHoverMenu openDrawer={openDrawer} /> + <NavHoverMenu openDrawer={openNavDrawer} /> <NavTabs /> </Group> <Group> <ScanButton /> + <ActionIcon onClick={openSearchDrawer}> + <IconSearch /> + </ActionIcon> <MainMenu /> </Group> </Group> diff --git a/src/frontend/src/components/nav/SearchDrawer.tsx b/src/frontend/src/components/nav/SearchDrawer.tsx new file mode 100644 index 0000000000..1fee52d466 --- /dev/null +++ b/src/frontend/src/components/nav/SearchDrawer.tsx @@ -0,0 +1,438 @@ +import { Trans, t } from '@lingui/macro'; +import { + ActionIcon, + Alert, + Center, + Checkbox, + Divider, + Drawer, + Group, + Menu, + Paper, + Space, + Stack, + Text, + TextInput +} from '@mantine/core'; +import { Loader } from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { + IconAlertCircle, + IconBackspace, + IconRefresh, + IconSearch, + IconSettings, + IconX +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { api } from '../../App'; + +// Define type for handling individual search queries +type SearchQuery = { + name: string; + title: string; + enabled: boolean; + parameters: any; + results?: any; + render: (result: any) => JSX.Element; +}; + +// Placeholder function for permissions checks (will be replaced with a proper implementation) +function permissionCheck(permission: string) { + return true; +} + +// Placeholder function for settings checks (will be replaced with a proper implementation) +function settingsCheck(setting: string) { + return true; +} + +// Placeholder function for rendering an individual search result +// In the future, this will be defined individually for each result type +function renderResult(result: any) { + return <Text size="sm">Result here - ID = {`${result.pk}`}</Text>; +} + +/* + * Build a list of search queries based on user permissions + */ +function buildSearchQueries(): SearchQuery[] { + return [ + { + name: 'part', + title: t`Parts`, + parameters: {}, + render: renderResult, + enabled: + permissionCheck('part.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_PARTS') + }, + { + name: 'supplierpart', + title: t`Supplier Parts`, + parameters: { + part_detail: true, + supplier_detail: true, + manufacturer_detail: true + }, + render: renderResult, + enabled: + permissionCheck('part.view') && + permissionCheck('purchase_order.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS') + }, + { + name: 'manufacturerpart', + title: t`Manufacturer Parts`, + parameters: { + part_detail: true, + supplier_detail: true, + manufacturer_detail: true + }, + render: renderResult, + enabled: + permissionCheck('part.view') && + permissionCheck('purchase_order.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS') + }, + { + name: 'partcategory', + title: t`Part Categories`, + parameters: {}, + render: renderResult, + enabled: + permissionCheck('part_category.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_CATEGORIES') + }, + { + name: 'stockitem', + title: t`Stock Items`, + parameters: { + part_detail: true, + location_detail: true + }, + render: renderResult, + enabled: + permissionCheck('stock.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_STOCK') + }, + { + name: 'stocklocation', + title: t`Stock Locations`, + parameters: {}, + render: renderResult, + enabled: + permissionCheck('stock_location.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_LOCATIONS') + }, + { + name: 'build', + title: t`Build Orders`, + parameters: { + part_detail: true + }, + render: renderResult, + enabled: + permissionCheck('build.view') && + settingsCheck('SEARCH_PREVIEW_SHOW_BUILD_ORDERS') + }, + { + name: 'company', + title: t`Companies`, + parameters: {}, + render: renderResult, + enabled: + (permissionCheck('sales_order.view') || + permissionCheck('purchase_order.view')) && + settingsCheck('SEARCH_PREVIEW_SHOW_COMPANIES') + }, + { + name: 'purchaseorder', + title: t`Purchase Orders`, + parameters: { + supplier_detail: true + }, + render: renderResult, + enabled: + permissionCheck('purchase_order.view') && + settingsCheck(`SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS`) + }, + { + name: 'salesorder', + title: t`Sales Orders`, + parameters: { + customer_detail: true + }, + render: renderResult, + enabled: + permissionCheck('sales_order.view') && + settingsCheck(`SEARCH_PREVIEW_SHOW_SALES_ORDERS`) + }, + { + name: 'returnorder', + title: t`Return Orders`, + parameters: { + customer_detail: true + }, + render: renderResult, + enabled: + permissionCheck('return_order.view') && + settingsCheck(`SEARCH_PREVIEW_SHOW_RETURN_ORDERS`) + } + ]; +} + +/* + * Render the results for a single search query + */ +function QueryResultGroup({ + query, + onRemove +}: { + query: SearchQuery; + onRemove: (query: string) => void; +}) { + if (query.results.count == 0) { + return null; + } + + return ( + <Paper shadow="sm" radius="xs" p="md"> + <Stack key={query.name}> + <Group position="apart" noWrap={true}> + <Group position="left" spacing={5} noWrap={true}> + <Text size="lg">{query.title}</Text> + <Text size="sm" italic> + {' '} + - {query.results.count} <Trans>results</Trans> + </Text> + </Group> + <Space /> + <ActionIcon + size="sm" + color="red" + variant="transparent" + radius="xs" + onClick={() => onRemove(query.name)} + > + <IconX /> + </ActionIcon> + </Group> + <Divider /> + <Stack> + {query.results.results.map((result: any) => query.render(result))} + </Stack> + <Space /> + </Stack> + </Paper> + ); +} + +/** + * Construct a drawer which provides quick-search functionality + * @param + */ +export function SearchDrawer({ + opened, + onClose +}: { + opened: boolean; + onClose: () => void; +}) { + const [value, setValue] = useState<string>(''); + const [searchText] = useDebouncedValue(value, 500); + + const [searchRegex, setSearchRegex] = useState<boolean>(false); + const [searchWhole, setSearchWhole] = useState<boolean>(false); + + // Construct a list of search queries based on user permissions + const searchQueries: SearchQuery[] = buildSearchQueries().filter( + (q) => q.enabled + ); + + // Re-fetch data whenever the search term is updated + useEffect(() => { + // TODO: Implement search functionality + refetch(); + }, [searchText]); + + // Function for performing the actual search query + const performSearch = async () => { + // Return empty result set if no search text + if (!searchText) { + return []; + } + + let params: any = { + offset: 0, + limit: 10, // TODO: Make this configurable (based on settings) + search: searchText, + search_regex: searchRegex, + search_whole: searchWhole + }; + + // Add in custom query parameters + searchQueries.forEach((query) => { + params[query.name] = query.parameters; + }); + + return api + .post(`/search/`, params) + .then(function (response) { + return response.data; + }) + .catch(function (error) { + console.error(error); + return []; + }); + }; + + // Search query manager + const { data, isError, isFetching, isLoading, refetch } = useQuery( + ['search', searchText, searchRegex, searchWhole], + performSearch, + { + refetchOnWindowFocus: false + } + ); + + // A list of queries which return valid results + const [queryResults, setQueryResults] = useState<SearchQuery[]>([]); + + // Update query results whenever the search results change + useEffect(() => { + if (data) { + let queries = searchQueries.filter((query) => query.name in data); + + for (let key in data) { + let query = queries.find((q) => q.name == key); + if (query) { + query.results = data[key]; + } + } + + // Filter for results with non-zero count + queries = queries.filter((query) => query.results.count > 0); + + setQueryResults(queries); + } else { + setQueryResults([]); + } + }, [data]); + + // Callback to remove a set of results from the list + function removeResults(query: string) { + setQueryResults(queryResults.filter((q) => q.name != query)); + } + + function closeDrawer() { + setValue(''); + onClose(); + } + + return ( + <Drawer + opened={opened} + size="lg" + onClose={closeDrawer} + position="right" + withCloseButton={false} + styles={{ header: { width: '100%' }, title: { width: '100%' } }} + title={ + <Group position="apart" spacing={1} noWrap={true}> + <TextInput + placeholder={t`Enter search text`} + radius="xs" + value={value} + onChange={(event) => setValue(event.currentTarget.value)} + icon={<IconSearch size="0.8rem" />} + rightSection={ + value && ( + <IconBackspace color="red" onClick={() => setValue('')} /> + ) + } + styles={{ root: { width: '100%' } }} + /> + <ActionIcon + size="lg" + variant="outline" + radius="xs" + onClick={() => refetch()} + > + <IconRefresh /> + </ActionIcon> + <Menu> + <Menu.Target> + <ActionIcon size="lg" variant="outline" radius="xs"> + <IconSettings /> + </ActionIcon> + </Menu.Target> + <Menu.Dropdown> + <Menu.Label>{t`Search Options`}</Menu.Label> + <Menu.Item> + <Checkbox + label={t`Regex search`} + checked={searchRegex} + onChange={(event) => + setSearchRegex(event.currentTarget.checked) + } + radius="sm" + /> + </Menu.Item> + <Menu.Item> + <Checkbox + label={t`Whole word search`} + checked={searchWhole} + onChange={(event) => + setSearchWhole(event.currentTarget.checked) + } + radius="sm" + /> + </Menu.Item> + </Menu.Dropdown> + </Menu> + </Group> + } + > + {isFetching && ( + <Center> + <Loader /> + </Center> + )} + {!isFetching && !isError && ( + <Stack spacing="md"> + {queryResults.map((query) => ( + <QueryResultGroup + query={query} + onRemove={(query) => removeResults(query)} + /> + ))} + </Stack> + )} + {isError && ( + <Alert + color="red" + radius="sm" + variant="light" + title={t`Error`} + icon={<IconAlertCircle size="1rem" />} + > + <Trans>An error occurred during search query</Trans> + </Alert> + )} + {searchText && !isFetching && !isError && queryResults.length == 0 && ( + <Alert + color="blue" + radius="sm" + variant="light" + title={t`No results`} + icon={<IconSearch size="1rem" />} + > + <Trans>No results available for search query</Trans> + </Alert> + )} + </Drawer> + ); +}