2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-09 16:58:49 +00:00

[UI] Search Improvements (#9137)

* Harden playwright tests

* Refactor search drawer

- Allow result groups to collapse

* Add tooltip

* Fix menu position

* Navigate through to complete list of results

* Refactor table headers

* Add index pages for SupplierPart and ManufacturerPart models

* backend: allow split search by company type

* Fix panel naming bug

* Fix model URLs

* Split company results by company type

- Allows better routing to results list

* Remove debug msg

* Fix 'button within button' issue

* Additional playwright tests
This commit is contained in:
Oliver 2025-02-22 15:00:25 +11:00 committed by GitHub
parent 09cdf72bda
commit 44cca7ddf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 290 additions and 121 deletions

View File

@ -522,6 +522,9 @@ class APISearchView(GenericAPIView):
return { return {
'build': build.api.BuildList, 'build': build.api.BuildList,
'company': company.api.CompanyList, 'company': company.api.CompanyList,
'supplier': company.api.CompanyList,
'manufacturer': company.api.CompanyList,
'customer': company.api.CompanyList,
'manufacturerpart': company.api.ManufacturerPartList, 'manufacturerpart': company.api.ManufacturerPartList,
'supplierpart': company.api.SupplierPartList, 'supplierpart': company.api.SupplierPartList,
'part': part.api.PartList, 'part': part.api.PartList,
@ -534,6 +537,14 @@ class APISearchView(GenericAPIView):
'stocklocation': stock.api.StockLocationList, 'stocklocation': stock.api.StockLocationList,
} }
def get_result_filters(self):
"""Provide extra filtering options for particular search groups."""
return {
'supplier': {'is_supplier': True},
'manufacturer': {'is_manufacturer': True},
'customer': {'is_customer': True},
}
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Perform search query against available models.""" """Perform search query against available models."""
data = request.data data = request.data
@ -552,6 +563,8 @@ class APISearchView(GenericAPIView):
if 'search' not in data: if 'search' not in data:
raise ValidationError({'search': 'Search term must be provided'}) raise ValidationError({'search': 'Search term must be provided'})
search_filters = self.get_result_filters()
for key, cls in self.get_result_types().items(): for key, cls in self.get_result_types().items():
# Only return results which are specifically requested # Only return results which are specifically requested
if key in data: if key in data:
@ -560,6 +573,11 @@ class APISearchView(GenericAPIView):
for k, v in pass_through_params.items(): for k, v in pass_through_params.items():
params[k] = request.data.get(k, v) params[k] = request.data.get(k, v)
# Add in any extra filters for this particular search type
if key in search_filters:
for k, v in search_filters[key].items():
params[k] = v
# Enforce json encoding # Enforce json encoding
params['format'] = 'json' params['format'] = 'json'

View File

@ -1,16 +1,15 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { import {
Accordion,
ActionIcon, ActionIcon,
Alert, Alert,
Anchor, Anchor,
Center, Center,
Checkbox, Checkbox,
Divider,
Drawer, Drawer,
Group, Group,
Loader, Loader,
Menu, Menu,
Paper,
Space, Space,
Stack, Stack,
Text, Text,
@ -21,19 +20,23 @@ import { useDebouncedValue } from '@mantine/hooks';
import { import {
IconAlertCircle, IconAlertCircle,
IconBackspace, IconBackspace,
IconExclamationCircle,
IconRefresh, IconRefresh,
IconSearch, IconSearch,
IconSettings, IconSettings,
IconTableExport,
IconX IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { type NavigateFunction, useNavigate } from 'react-router-dom';
import { showNotification } from '@mantine/notifications';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { cancelEvent } from '../../functions/events';
import { navigateToLink } from '../../functions/navigation'; import { navigateToLink } from '../../functions/navigation';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserSettingsState } from '../../states/SettingsState'; import { useUserSettingsState } from '../../states/SettingsState';
@ -45,6 +48,9 @@ import { ModelInformationDict, getModelInfo } from '../render/ModelType';
// Define type for handling individual search queries // Define type for handling individual search queries
type SearchQuery = { type SearchQuery = {
model: ModelType; model: ModelType;
searchKey?: string;
title?: string;
overviewUrl?: string;
enabled: boolean; enabled: boolean;
parameters: any; parameters: any;
results?: any; results?: any;
@ -54,38 +60,86 @@ type SearchQuery = {
* Render the results for a single search query * Render the results for a single search query
*/ */
function QueryResultGroup({ function QueryResultGroup({
searchText,
query, query,
navigate,
onClose,
onRemove, onRemove,
onResultClick onResultClick
}: Readonly<{ }: Readonly<{
searchText: string;
query: SearchQuery; query: SearchQuery;
navigate: NavigateFunction;
onClose: () => void;
onRemove: (query: ModelType) => void; onRemove: (query: ModelType) => void;
onResultClick: (query: ModelType, pk: number, event: any) => void; onResultClick: (query: ModelType, pk: number, event: any) => void;
}>) { }>) {
const modelInfo = useMemo(() => getModelInfo(query.model), [query.model]);
const overviewUrl: string | undefined = useMemo(() => {
// Query has a custom overview URL
if (query.overviewUrl) {
return query.overviewUrl;
}
return modelInfo.url_overview;
}, [query, modelInfo]);
// Callback function to view all results for a given query
const viewResults = useCallback(
(event: any) => {
cancelEvent(event);
if (overviewUrl) {
const url = `${overviewUrl}?search=${searchText}`;
// Close drawer if opening in the same tab
if (!(event?.ctrlKey || event?.shiftKey)) {
onClose();
}
navigateToLink(url, navigate, event);
} else {
showNotification({
title: t`No Overview Available`,
message: t`No overview available for this model type`,
color: 'red',
icon: <IconExclamationCircle />
});
}
},
[overviewUrl, searchText]
);
if (query.results.count == 0) { if (query.results.count == 0) {
return null; return null;
} }
const model = getModelInfo(query.model);
return ( return (
<Paper <Accordion.Item key={query.model} value={query.model}>
withBorder <Accordion.Control component='div'>
shadow='sm' <Group justify='space-between'>
p='md'
key={`paper-${query.model}`}
aria-label={`search-group-${query.model}`}
>
<Stack key={`stack-${query.model}`}>
<Group justify='space-between' wrap='nowrap'>
<Group justify='left' gap={5} wrap='nowrap'> <Group justify='left' gap={5} wrap='nowrap'>
<Text size='lg'>{model.label_multiple}</Text> <Tooltip label={t`View all results`} position='top-start'>
<ActionIcon
size='sm'
variant='transparent'
radius='xs'
aria-label={`view-all-results-${query.searchKey ?? query.model}`}
disabled={!overviewUrl}
onClick={viewResults}
>
<IconTableExport />
</ActionIcon>
</Tooltip>
<Text size='lg'>{query.title ?? modelInfo.label_multiple}</Text>
<Text size='sm' style={{ fontStyle: 'italic' }}> <Text size='sm' style={{ fontStyle: 'italic' }}>
{' '} {' '}
- {query.results.count} <Trans>results</Trans> - {query.results.count} <Trans>results</Trans>
</Text> </Text>
</Group> </Group>
<Space /> <Group justify='right' wrap='nowrap'>
<Tooltip label={t`Remove search group`} position='top-end'>
<ActionIcon <ActionIcon
size='sm' size='sm'
color='red' color='red'
@ -96,8 +150,12 @@ function QueryResultGroup({
> >
<IconX /> <IconX />
</ActionIcon> </ActionIcon>
</Tooltip>
<Space />
</Group> </Group>
<Divider /> </Group>
</Accordion.Control>
<Accordion.Panel>
<Stack aria-label={`search-group-results-${query.model}`}> <Stack aria-label={`search-group-results-${query.model}`}>
{query.results.results.map((result: any) => ( {query.results.results.map((result: any) => (
<Anchor <Anchor
@ -111,9 +169,8 @@ function QueryResultGroup({
</Anchor> </Anchor>
))} ))}
</Stack> </Stack>
<Space /> </Accordion.Panel>
</Stack> </Accordion.Item>
</Paper>
); );
} }
@ -213,10 +270,32 @@ export function SearchDrawer({
}, },
{ {
model: ModelType.company, model: ModelType.company,
overviewUrl: '/purchasing/index/suppliers',
searchKey: 'supplier',
title: t`Suppliers`,
parameters: {}, parameters: {},
enabled: enabled:
(user.hasViewRole(UserRoles.sales_order) || user.hasViewRole(UserRoles.purchase_order) &&
user.hasViewRole(UserRoles.purchase_order)) && userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES')
},
{
model: ModelType.company,
overviewUrl: '/purchasing/index/manufacturers',
searchKey: 'manufacturer',
title: t`Manufacturers`,
parameters: {},
enabled:
user.hasViewRole(UserRoles.purchase_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES')
},
{
model: ModelType.company,
overviewUrl: '/sales/index/customers',
searchKey: 'customer',
title: t`Customers`,
parameters: {},
enabled:
user.hasViewRole(UserRoles.sales_order) &&
userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES') userSettings.isSet('SEARCH_PREVIEW_SHOW_COMPANIES')
}, },
{ {
@ -272,7 +351,9 @@ export function SearchDrawer({
}, [user, userSettings]); }, [user, userSettings]);
// Construct a list of search queries based on user permissions // Construct a list of search queries based on user permissions
const searchQueries: SearchQuery[] = searchQueryList.filter((q) => q.enabled); const searchQueries: SearchQuery[] = useMemo(() => {
return searchQueryList.filter((q) => q.enabled);
}, [searchQueryList]);
// Re-fetch data whenever the search term is updated // Re-fetch data whenever the search term is updated
useEffect(() => { useEffect(() => {
@ -296,7 +377,8 @@ export function SearchDrawer({
// Add in custom query parameters // Add in custom query parameters
searchQueries.forEach((query) => { searchQueries.forEach((query) => {
params[query.model] = query.parameters; const key = query.searchKey || query.model;
params[key] = query.parameters;
}); });
return api return api
@ -321,11 +403,11 @@ export function SearchDrawer({
useEffect(() => { useEffect(() => {
if (searchQuery.data) { if (searchQuery.data) {
let queries = searchQueries.filter( let queries = searchQueries.filter(
(query) => query.model in searchQuery.data (query) => (query.searchKey ?? query.model) in searchQuery.data
); );
for (const key in searchQuery.data) { for (const key in searchQuery.data) {
const query = queries.find((q) => q.model == key); const query = queries.find((q) => (q.searchKey ?? q.model) == key);
if (query) { if (query) {
query.results = searchQuery.data[key]; query.results = searchQuery.data[key];
} }
@ -402,7 +484,7 @@ export function SearchDrawer({
<IconRefresh /> <IconRefresh />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Menu> <Menu position='bottom-end'>
<Menu.Target> <Menu.Target>
<Tooltip label={t`Search Options`} position='bottom-end'> <Tooltip label={t`Search Options`} position='bottom-end'>
<ActionIcon size='lg' variant='transparent'> <ActionIcon size='lg' variant='transparent'>
@ -443,16 +525,24 @@ export function SearchDrawer({
)} )}
{!searchQuery.isFetching && !searchQuery.isError && ( {!searchQuery.isFetching && !searchQuery.isError && (
<Stack gap='md'> <Stack gap='md'>
<Accordion
multiple
defaultValue={searchQueries.map((q) => q.model)}
>
{queryResults.map((query, idx) => ( {queryResults.map((query, idx) => (
<QueryResultGroup <QueryResultGroup
key={idx} key={idx}
searchText={searchText}
query={query} query={query}
navigate={navigate}
onClose={closeDrawer}
onRemove={(query) => removeResults(query)} onRemove={(query) => removeResults(query)}
onResultClick={(query, pk, event) => onResultClick={(query, pk, event) =>
onResultClick(query, pk, event) onResultClick(query, pk, event)
} }
/> />
))} ))}
</Accordion>
</Stack> </Stack>
)} )}
{searchQuery.isError && ( {searchQuery.isError && (

View File

@ -37,7 +37,6 @@ export const ModelInformationDict: ModelDict = {
partparametertemplate: { partparametertemplate: {
label: () => t`Part Parameter Template`, label: () => t`Part Parameter Template`,
label_multiple: () => t`Part Parameter Templates`, label_multiple: () => t`Part Parameter Templates`,
url_overview: '/partparametertemplate',
url_detail: '/partparametertemplate/:pk/', url_detail: '/partparametertemplate/:pk/',
api_endpoint: ApiEndpoints.part_parameter_template_list, api_endpoint: ApiEndpoints.part_parameter_template_list,
icon: 'test_templates' icon: 'test_templates'
@ -45,7 +44,6 @@ export const ModelInformationDict: ModelDict = {
parttesttemplate: { parttesttemplate: {
label: () => t`Part Test Template`, label: () => t`Part Test Template`,
label_multiple: () => t`Part Test Templates`, label_multiple: () => t`Part Test Templates`,
url_overview: '/parttesttemplate',
url_detail: '/parttesttemplate/:pk/', url_detail: '/parttesttemplate/:pk/',
api_endpoint: ApiEndpoints.part_test_template_list, api_endpoint: ApiEndpoints.part_test_template_list,
icon: 'test' icon: 'test'
@ -53,7 +51,7 @@ export const ModelInformationDict: ModelDict = {
supplierpart: { supplierpart: {
label: () => t`Supplier Part`, label: () => t`Supplier Part`,
label_multiple: () => t`Supplier Parts`, label_multiple: () => t`Supplier Parts`,
url_overview: '/supplierpart', url_overview: '/purchasing/index/supplier-parts',
url_detail: '/purchasing/supplier-part/:pk/', url_detail: '/purchasing/supplier-part/:pk/',
api_endpoint: ApiEndpoints.supplier_part_list, api_endpoint: ApiEndpoints.supplier_part_list,
admin_url: '/company/supplierpart/', admin_url: '/company/supplierpart/',
@ -62,7 +60,7 @@ export const ModelInformationDict: ModelDict = {
manufacturerpart: { manufacturerpart: {
label: () => t`Manufacturer Part`, label: () => t`Manufacturer Part`,
label_multiple: () => t`Manufacturer Parts`, label_multiple: () => t`Manufacturer Parts`,
url_overview: '/manufacturerpart', url_overview: '/purchasing/index/manufacturer-parts',
url_detail: '/purchasing/manufacturer-part/:pk/', url_detail: '/purchasing/manufacturer-part/:pk/',
api_endpoint: ApiEndpoints.manufacturer_part_list, api_endpoint: ApiEndpoints.manufacturer_part_list,
admin_url: '/company/manufacturerpart/', admin_url: '/company/manufacturerpart/',
@ -133,7 +131,6 @@ export const ModelInformationDict: ModelDict = {
company: { company: {
label: () => t`Company`, label: () => t`Company`,
label_multiple: () => t`Companies`, label_multiple: () => t`Companies`,
url_overview: '/company',
url_detail: '/company/:pk/', url_detail: '/company/:pk/',
api_endpoint: ApiEndpoints.company_list, api_endpoint: ApiEndpoints.company_list,
admin_url: '/company/company/', admin_url: '/company/company/',
@ -142,7 +139,6 @@ export const ModelInformationDict: ModelDict = {
projectcode: { projectcode: {
label: () => t`Project Code`, label: () => t`Project Code`,
label_multiple: () => t`Project Codes`, label_multiple: () => t`Project Codes`,
url_overview: '/project-code',
url_detail: '/project-code/:pk/', url_detail: '/project-code/:pk/',
api_endpoint: ApiEndpoints.project_code_list, api_endpoint: ApiEndpoints.project_code_list,
icon: 'list_details' icon: 'list_details'
@ -174,7 +170,6 @@ export const ModelInformationDict: ModelDict = {
salesordershipment: { salesordershipment: {
label: () => t`Sales Order Shipment`, label: () => t`Sales Order Shipment`,
label_multiple: () => t`Sales Order Shipments`, label_multiple: () => t`Sales Order Shipments`,
url_overview: '/sales/shipment/',
url_detail: '/sales/shipment/:pk/', url_detail: '/sales/shipment/:pk/',
api_endpoint: ApiEndpoints.sales_order_shipment_list, api_endpoint: ApiEndpoints.sales_order_shipment_list,
icon: 'sales_orders' icon: 'sales_orders'
@ -197,7 +192,6 @@ export const ModelInformationDict: ModelDict = {
address: { address: {
label: () => t`Address`, label: () => t`Address`,
label_multiple: () => t`Addresses`, label_multiple: () => t`Addresses`,
url_overview: '/address',
url_detail: '/address/:pk/', url_detail: '/address/:pk/',
api_endpoint: ApiEndpoints.address_list, api_endpoint: ApiEndpoints.address_list,
icon: 'address' icon: 'address'
@ -205,7 +199,6 @@ export const ModelInformationDict: ModelDict = {
contact: { contact: {
label: () => t`Contact`, label: () => t`Contact`,
label_multiple: () => t`Contacts`, label_multiple: () => t`Contacts`,
url_overview: '/contact',
url_detail: '/contact/:pk/', url_detail: '/contact/:pk/',
api_endpoint: ApiEndpoints.contact_list, api_endpoint: ApiEndpoints.contact_list,
icon: 'group' icon: 'group'
@ -213,7 +206,6 @@ export const ModelInformationDict: ModelDict = {
owner: { owner: {
label: () => t`Owner`, label: () => t`Owner`,
label_multiple: () => t`Owners`, label_multiple: () => t`Owners`,
url_overview: '/owner',
url_detail: '/owner/:pk/', url_detail: '/owner/:pk/',
api_endpoint: ApiEndpoints.owner_list, api_endpoint: ApiEndpoints.owner_list,
icon: 'group' icon: 'group'
@ -221,7 +213,6 @@ export const ModelInformationDict: ModelDict = {
user: { user: {
label: () => t`User`, label: () => t`User`,
label_multiple: () => t`Users`, label_multiple: () => t`Users`,
url_overview: '/user',
url_detail: '/user/:pk/', url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list, api_endpoint: ApiEndpoints.user_list,
icon: 'user' icon: 'user'
@ -229,7 +220,6 @@ export const ModelInformationDict: ModelDict = {
group: { group: {
label: () => t`Group`, label: () => t`Group`,
label_multiple: () => t`Groups`, label_multiple: () => t`Groups`,
url_overview: '/user/group',
url_detail: '/user/group-:pk', url_detail: '/user/group-:pk',
api_endpoint: ApiEndpoints.group_list, api_endpoint: ApiEndpoints.group_list,
admin_url: '/auth/group/', admin_url: '/auth/group/',
@ -238,7 +228,7 @@ export const ModelInformationDict: ModelDict = {
importsession: { importsession: {
label: () => t`Import Session`, label: () => t`Import Session`,
label_multiple: () => t`Import Sessions`, label_multiple: () => t`Import Sessions`,
url_overview: '/import', url_overview: '/settings/admin/import',
url_detail: '/import/:pk/', url_detail: '/import/:pk/',
api_endpoint: ApiEndpoints.import_session_list, api_endpoint: ApiEndpoints.import_session_list,
icon: 'import' icon: 'import'
@ -246,24 +236,24 @@ export const ModelInformationDict: ModelDict = {
labeltemplate: { labeltemplate: {
label: () => t`Label Template`, label: () => t`Label Template`,
label_multiple: () => t`Label Templates`, label_multiple: () => t`Label Templates`,
url_overview: '/labeltemplate', url_overview: '/settings/admin/labels',
url_detail: '/labeltemplate/:pk/', url_detail: '/settings/admin/labels/:pk/',
api_endpoint: ApiEndpoints.label_list, api_endpoint: ApiEndpoints.label_list,
icon: 'labels' icon: 'labels'
}, },
reporttemplate: { reporttemplate: {
label: () => t`Report Template`, label: () => t`Report Template`,
label_multiple: () => t`Report Templates`, label_multiple: () => t`Report Templates`,
url_overview: '/reporttemplate', url_overview: '/settings/admin/reports',
url_detail: '/reporttemplate/:pk/', url_detail: '/settings/admin/reports/:pk/',
api_endpoint: ApiEndpoints.report_list, api_endpoint: ApiEndpoints.report_list,
icon: 'reports' icon: 'reports'
}, },
pluginconfig: { pluginconfig: {
label: () => t`Plugin Configuration`, label: () => t`Plugin Configuration`,
label_multiple: () => t`Plugin Configurations`, label_multiple: () => t`Plugin Configurations`,
url_overview: '/pluginconfig', url_overview: '/settings/admin/plugin',
url_detail: '/pluginconfig/:pk/', url_detail: '/settings/admin/plugin/:pk/',
api_endpoint: ApiEndpoints.plugin_list, api_endpoint: ApiEndpoints.plugin_list,
icon: 'plugin' icon: 'plugin'
}, },

View File

@ -1,7 +1,6 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Grid, Skeleton, Stack } from '@mantine/core'; import { Grid, Skeleton, Stack } from '@mantine/core';
import { import {
IconBuildingFactory2,
IconBuildingWarehouse, IconBuildingWarehouse,
IconInfoCircle, IconInfoCircle,
IconMap2, IconMap2,
@ -176,24 +175,24 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
icon: <IconInfoCircle />, icon: <IconInfoCircle />,
content: detailsPanel content: detailsPanel
}, },
{
name: 'manufactured-parts',
label: t`Manufactured Parts`,
icon: <IconBuildingFactory2 />,
hidden: !company?.is_manufacturer,
content: company?.pk && (
<ManufacturerPartTable params={{ manufacturer: company.pk }} />
)
},
{ {
name: 'supplied-parts', name: 'supplied-parts',
label: t`Supplied Parts`, label: t`Supplied Parts`,
icon: <IconBuildingWarehouse />, icon: <IconPackageExport />,
hidden: !company?.is_supplier, hidden: !company?.is_supplier,
content: company?.pk && ( content: company?.pk && (
<SupplierPartTable params={{ supplier: company.pk }} /> <SupplierPartTable params={{ supplier: company.pk }} />
) )
}, },
{
name: 'manufactured-parts',
label: t`Manufactured Parts`,
icon: <IconBuildingWarehouse />,
hidden: !company?.is_manufacturer,
content: company?.pk && (
<ManufacturerPartTable params={{ manufacturer: company.pk }} />
)
},
{ {
name: 'purchase-orders', name: 'purchase-orders',
label: t`Purchase Orders`, label: t`Purchase Orders`,

View File

@ -3,6 +3,8 @@ import { Stack } from '@mantine/core';
import { import {
IconBuildingFactory2, IconBuildingFactory2,
IconBuildingStore, IconBuildingStore,
IconBuildingWarehouse,
IconPackageExport,
IconShoppingCart IconShoppingCart
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -13,7 +15,9 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { CompanyTable } from '../../tables/company/CompanyTable'; import { CompanyTable } from '../../tables/company/CompanyTable';
import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable';
export default function PurchasingIndex() { export default function PurchasingIndex() {
const user = useUserState(); const user = useUserState();
@ -38,6 +42,12 @@ export default function PurchasingIndex() {
/> />
) )
}, },
{
name: 'supplier-parts',
label: t`Supplier Parts`,
icon: <IconPackageExport />,
content: <SupplierPartTable params={{}} />
},
{ {
name: 'manufacturer', name: 'manufacturer',
label: t`Manufacturers`, label: t`Manufacturers`,
@ -48,6 +58,12 @@ export default function PurchasingIndex() {
params={{ is_manufacturer: true }} params={{ is_manufacturer: true }}
/> />
) )
},
{
name: 'manufacturer-parts',
label: t`Manufacturer Parts`,
icon: <IconBuildingWarehouse />,
content: <ManufacturerPartTable params={{}} />
} }
]; ];
}, [user]); }, [user]);

View File

@ -36,7 +36,7 @@ export default function PurchasingIndex() {
hidden: !user.hasViewRole(UserRoles.return_order) hidden: !user.hasViewRole(UserRoles.return_order)
}, },
{ {
name: 'suppliers', name: 'customers',
label: t`Customers`, label: t`Customers`,
icon: <IconBuildingStore />, icon: <IconBuildingStore />,
content: ( content: (

View File

@ -13,7 +13,7 @@ import {
IconRefresh, IconRefresh,
IconTrash IconTrash
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Fragment } from 'react/jsx-runtime'; import { Fragment } from 'react/jsx-runtime';
import { Boundary } from '../components/Boundary'; import { Boundary } from '../components/Boundary';
@ -122,6 +122,18 @@ export default function InvenTreeTableHeader({
} }
}); });
const hasCustomSearch = useMemo(() => {
return tableState.queryFilters.has('search');
}, [tableState.queryFilters]);
const hasCustomFilters = useMemo(() => {
if (hasCustomSearch) {
return tableState.queryFilters.size > 1;
} else {
return tableState.queryFilters.size > 0;
}
}, [hasCustomSearch, tableState.queryFilters]);
return ( return (
<> <>
{deleteRecords.modal} {deleteRecords.modal}
@ -135,7 +147,7 @@ export default function InvenTreeTableHeader({
/> />
</Boundary> </Boundary>
)} )}
{tableState.queryFilters.size > 0 && ( {(hasCustomFilters || hasCustomSearch) && (
<Alert <Alert
color='yellow' color='yellow'
withCloseButton withCloseButton
@ -143,7 +155,6 @@ export default function InvenTreeTableHeader({
onClose={() => tableState.clearQueryFilters()} onClose={() => tableState.clearQueryFilters()}
/> />
)} )}
<Group justify='apart' grow wrap='nowrap'> <Group justify='apart' grow wrap='nowrap'>
<Group justify='left' key='custom-actions' gap={5} wrap='nowrap'> <Group justify='left' key='custom-actions' gap={5} wrap='nowrap'>
<PrintingActions <PrintingActions
@ -180,6 +191,7 @@ export default function InvenTreeTableHeader({
<Group justify='right' gap={5} wrap='nowrap'> <Group justify='right' gap={5} wrap='nowrap'>
{tableProps.enableSearch && ( {tableProps.enableSearch && (
<TableSearchInput <TableSearchInput
disabled={hasCustomSearch}
searchCallback={(term: string) => tableState.setSearchTerm(term)} searchCallback={(term: string) => tableState.setSearchTerm(term)}
/> />
)} )}
@ -208,6 +220,7 @@ export default function InvenTreeTableHeader({
disabled={tableState.activeFilters?.length == 0} disabled={tableState.activeFilters?.length == 0}
> >
<ActionIcon <ActionIcon
disabled={hasCustomFilters}
variant='transparent' variant='transparent'
aria-label='table-select-filters' aria-label='table-select-filters'
> >

View File

@ -5,8 +5,10 @@ import { IconSearch } from '@tabler/icons-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function TableSearchInput({ export function TableSearchInput({
disabled,
searchCallback searchCallback
}: Readonly<{ }: Readonly<{
disabled?: boolean;
searchCallback: (searchTerm: string) => void; searchCallback: (searchTerm: string) => void;
}>) { }>) {
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@ -19,6 +21,7 @@ export function TableSearchInput({
return ( return (
<TextInput <TextInput
value={value} value={value}
disabled={disabled}
leftSection={<IconSearch />} leftSection={<IconSearch />}
placeholder={t`Search`} placeholder={t`Search`}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}

View File

@ -73,7 +73,7 @@ export function SupplierPartTable({
DescriptionColumn({}), DescriptionColumn({}),
{ {
accessor: 'manufacturer', accessor: 'manufacturer',
title: t`Manufacturer`,
sortable: true, sortable: true,
render: (record: any) => { render: (record: any) => {
const manufacturer = record?.manufacturer_detail ?? {}; const manufacturer = record?.manufacturer_detail ?? {};

View File

@ -97,4 +97,5 @@ export const globalSearch = async (page, query) => {
await page.getByLabel('global-search-input').clear(); await page.getByLabel('global-search-input').clear();
await page.getByPlaceholder('Enter search text').fill(query); await page.getByPlaceholder('Enter search text').fill(query);
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.waitForLoadState('networkidle');
}; };

View File

@ -0,0 +1,40 @@
import { test } from '../baseFixtures.js';
import { navigate } from '../helpers.js';
import { doQuickLogin } from '../login.js';
test('Company', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'company/1/details');
await page.getByLabel('Details').getByText('DigiKey Electronics').waitFor();
await page.getByRole('cell', { name: 'https://www.digikey.com/' }).waitFor();
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
await page
.getByRole('cell', { name: 'RR05P100KDTR-ND', exact: true })
.waitFor();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('cell', { name: 'Molex connectors' }).first().waitFor();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page
.getByRole('cell', { name: 'Blue plastic enclosure' })
.first()
.waitFor();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('cell', { name: 'jimmy.mcleod@digikey.com' }).waitFor();
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('cell', { name: 'Carla Tunnel' }).waitFor();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
// Let's edit the company details
await page.getByLabel('action-menu-company-actions').click();
await page.getByLabel('action-menu-company-actions-edit').click();
await page.getByLabel('text-field-name').fill('');
await page.getByLabel('text-field-website').fill('invalid-website');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('This field may not be blank.').waitFor();
await page.getByText('Enter a valid URL.').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});

View File

@ -1,44 +1,7 @@
import { test } from './baseFixtures.js'; import { test } from './baseFixtures.js';
import { navigate } from './helpers.js'; import { globalSearch, navigate } from './helpers.js';
import { doQuickLogin } from './login.js'; import { doQuickLogin } from './login.js';
test('Company', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'company/1/details');
await page.getByLabel('Details').getByText('DigiKey Electronics').waitFor();
await page.getByRole('cell', { name: 'https://www.digikey.com/' }).waitFor();
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
await page
.getByRole('cell', { name: 'RR05P100KDTR-ND', exact: true })
.waitFor();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('cell', { name: 'Molex connectors' }).first().waitFor();
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page
.getByRole('cell', { name: 'Blue plastic enclosure' })
.first()
.waitFor();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('cell', { name: 'jimmy.mcleod@digikey.com' }).waitFor();
await page.getByRole('tab', { name: 'Addresses' }).click();
await page.getByRole('cell', { name: 'Carla Tunnel' }).waitFor();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
// Let's edit the company details
await page.getByLabel('action-menu-company-actions').click();
await page.getByLabel('action-menu-company-actions-edit').click();
await page.getByLabel('text-field-name').fill('');
await page.getByLabel('text-field-website').fill('invalid-website');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('This field may not be blank.').waitFor();
await page.getByText('Enter a valid URL.').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
});
/** /**
* Test for integration of django admin button * Test for integration of django admin button
*/ */
@ -53,3 +16,39 @@ test('Admin Button', async ({ page }) => {
await page.getByRole('heading', { name: 'Change Company' }).waitFor(); await page.getByRole('heading', { name: 'Change Company' }).waitFor();
await page.getByRole('link', { name: 'View on site' }).waitFor(); await page.getByRole('link', { name: 'View on site' }).waitFor();
}); });
// Tests for the global search functionality
test('Search', async ({ page }) => {
await doQuickLogin(page, 'steven', 'wizardstaff');
await globalSearch(page, 'another customer');
// Check for expected results
await page.locator('a').filter({ hasText: 'Customer B' }).first().waitFor();
await page.locator('a').filter({ hasText: 'Customer C' }).first().waitFor();
await page.locator('a').filter({ hasText: 'Customer D' }).first().waitFor();
await page.locator('a').filter({ hasText: 'Customer E' }).first().waitFor();
// Click through to the "Customer" results
await page.getByRole('button', { name: 'view-all-results-customer' }).click();
await page.waitForURL('**/sales/index/customers**');
await page.getByText('Custom table filters are active').waitFor();
await globalSearch(page, '0402 res');
await page
.locator('span')
.filter({ hasText: 'Parts - 16 results' })
.first()
.waitFor();
await page
.locator('span')
.filter({ hasText: 'Supplier Parts - 138 results' })
.first()
.waitFor();
await page.getByLabel('view-all-results-manufacturerpart').click();
await page.waitForURL('**/purchasing/index/manufacturer-parts**');
await page.getByRole('cell', { name: 'RT0402BRD07100KL' }).waitFor();
});