mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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
This commit is contained in:
parent
39464f645a
commit
7e7d4d01a2
@ -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
|
||||
|
@ -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>
|
||||
|
438
src/frontend/src/components/nav/SearchDrawer.tsx
Normal file
438
src/frontend/src/components/nav/SearchDrawer.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user