mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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:
		| @@ -522,6 +522,9 @@ class APISearchView(GenericAPIView): | ||||
|         return { | ||||
|             'build': build.api.BuildList, | ||||
|             'company': company.api.CompanyList, | ||||
|             'supplier': company.api.CompanyList, | ||||
|             'manufacturer': company.api.CompanyList, | ||||
|             'customer': company.api.CompanyList, | ||||
|             'manufacturerpart': company.api.ManufacturerPartList, | ||||
|             'supplierpart': company.api.SupplierPartList, | ||||
|             'part': part.api.PartList, | ||||
| @@ -534,6 +537,14 @@ class APISearchView(GenericAPIView): | ||||
|             '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): | ||||
|         """Perform search query against available models.""" | ||||
|         data = request.data | ||||
| @@ -552,6 +563,8 @@ class APISearchView(GenericAPIView): | ||||
|         if 'search' not in data: | ||||
|             raise ValidationError({'search': 'Search term must be provided'}) | ||||
|  | ||||
|         search_filters = self.get_result_filters() | ||||
|  | ||||
|         for key, cls in self.get_result_types().items(): | ||||
|             # Only return results which are specifically requested | ||||
|             if key in data: | ||||
| @@ -560,6 +573,11 @@ class APISearchView(GenericAPIView): | ||||
|                 for k, v in pass_through_params.items(): | ||||
|                     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 | ||||
|                 params['format'] = 'json' | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,15 @@ | ||||
| import { Trans, t } from '@lingui/macro'; | ||||
| import { | ||||
|   Accordion, | ||||
|   ActionIcon, | ||||
|   Alert, | ||||
|   Anchor, | ||||
|   Center, | ||||
|   Checkbox, | ||||
|   Divider, | ||||
|   Drawer, | ||||
|   Group, | ||||
|   Loader, | ||||
|   Menu, | ||||
|   Paper, | ||||
|   Space, | ||||
|   Stack, | ||||
|   Text, | ||||
| @@ -21,19 +20,23 @@ import { useDebouncedValue } from '@mantine/hooks'; | ||||
| import { | ||||
|   IconAlertCircle, | ||||
|   IconBackspace, | ||||
|   IconExclamationCircle, | ||||
|   IconRefresh, | ||||
|   IconSearch, | ||||
|   IconSettings, | ||||
|   IconTableExport, | ||||
|   IconX | ||||
| } from '@tabler/icons-react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | ||||
| import { type NavigateFunction, useNavigate } from 'react-router-dom'; | ||||
|  | ||||
| import { showNotification } from '@mantine/notifications'; | ||||
| import { api } from '../../App'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| import { cancelEvent } from '../../functions/events'; | ||||
| import { navigateToLink } from '../../functions/navigation'; | ||||
| import { apiUrl } from '../../states/ApiState'; | ||||
| import { useUserSettingsState } from '../../states/SettingsState'; | ||||
| @@ -45,6 +48,9 @@ import { ModelInformationDict, getModelInfo } from '../render/ModelType'; | ||||
| // Define type for handling individual search queries | ||||
| type SearchQuery = { | ||||
|   model: ModelType; | ||||
|   searchKey?: string; | ||||
|   title?: string; | ||||
|   overviewUrl?: string; | ||||
|   enabled: boolean; | ||||
|   parameters: any; | ||||
|   results?: any; | ||||
| @@ -54,38 +60,86 @@ type SearchQuery = { | ||||
|  * Render the results for a single search query | ||||
|  */ | ||||
| function QueryResultGroup({ | ||||
|   searchText, | ||||
|   query, | ||||
|   navigate, | ||||
|   onClose, | ||||
|   onRemove, | ||||
|   onResultClick | ||||
| }: Readonly<{ | ||||
|   searchText: string; | ||||
|   query: SearchQuery; | ||||
|   navigate: NavigateFunction; | ||||
|   onClose: () => void; | ||||
|   onRemove: (query: ModelType) => 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) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   const model = getModelInfo(query.model); | ||||
|  | ||||
|   return ( | ||||
|     <Paper | ||||
|       withBorder | ||||
|       shadow='sm' | ||||
|       p='md' | ||||
|       key={`paper-${query.model}`} | ||||
|       aria-label={`search-group-${query.model}`} | ||||
|     > | ||||
|       <Stack key={`stack-${query.model}`}> | ||||
|         <Group justify='space-between' wrap='nowrap'> | ||||
|     <Accordion.Item key={query.model} value={query.model}> | ||||
|       <Accordion.Control component='div'> | ||||
|         <Group justify='space-between'> | ||||
|           <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' }}> | ||||
|               {' '} | ||||
|               - {query.results.count} <Trans>results</Trans> | ||||
|             </Text> | ||||
|           </Group> | ||||
|           <Space /> | ||||
|           <Group justify='right' wrap='nowrap'> | ||||
|             <Tooltip label={t`Remove search group`} position='top-end'> | ||||
|               <ActionIcon | ||||
|                 size='sm' | ||||
|                 color='red' | ||||
| @@ -96,8 +150,12 @@ function QueryResultGroup({ | ||||
|               > | ||||
|                 <IconX /> | ||||
|               </ActionIcon> | ||||
|             </Tooltip> | ||||
|             <Space /> | ||||
|           </Group> | ||||
|         <Divider /> | ||||
|         </Group> | ||||
|       </Accordion.Control> | ||||
|       <Accordion.Panel> | ||||
|         <Stack aria-label={`search-group-results-${query.model}`}> | ||||
|           {query.results.results.map((result: any) => ( | ||||
|             <Anchor | ||||
| @@ -111,9 +169,8 @@ function QueryResultGroup({ | ||||
|             </Anchor> | ||||
|           ))} | ||||
|         </Stack> | ||||
|         <Space /> | ||||
|       </Stack> | ||||
|     </Paper> | ||||
|       </Accordion.Panel> | ||||
|     </Accordion.Item> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @@ -213,10 +270,32 @@ export function SearchDrawer({ | ||||
|       }, | ||||
|       { | ||||
|         model: ModelType.company, | ||||
|         overviewUrl: '/purchasing/index/suppliers', | ||||
|         searchKey: 'supplier', | ||||
|         title: t`Suppliers`, | ||||
|         parameters: {}, | ||||
|         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') | ||||
|       }, | ||||
|       { | ||||
| @@ -272,7 +351,9 @@ export function SearchDrawer({ | ||||
|   }, [user, userSettings]); | ||||
|  | ||||
|   // 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 | ||||
|   useEffect(() => { | ||||
| @@ -296,7 +377,8 @@ export function SearchDrawer({ | ||||
|  | ||||
|     // Add in custom query parameters | ||||
|     searchQueries.forEach((query) => { | ||||
|       params[query.model] = query.parameters; | ||||
|       const key = query.searchKey || query.model; | ||||
|       params[key] = query.parameters; | ||||
|     }); | ||||
|  | ||||
|     return api | ||||
| @@ -321,11 +403,11 @@ export function SearchDrawer({ | ||||
|   useEffect(() => { | ||||
|     if (searchQuery.data) { | ||||
|       let queries = searchQueries.filter( | ||||
|         (query) => query.model in searchQuery.data | ||||
|         (query) => (query.searchKey ?? query.model) 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) { | ||||
|           query.results = searchQuery.data[key]; | ||||
|         } | ||||
| @@ -402,7 +484,7 @@ export function SearchDrawer({ | ||||
|               <IconRefresh /> | ||||
|             </ActionIcon> | ||||
|           </Tooltip> | ||||
|           <Menu> | ||||
|           <Menu position='bottom-end'> | ||||
|             <Menu.Target> | ||||
|               <Tooltip label={t`Search Options`} position='bottom-end'> | ||||
|                 <ActionIcon size='lg' variant='transparent'> | ||||
| @@ -443,16 +525,24 @@ export function SearchDrawer({ | ||||
|         )} | ||||
|         {!searchQuery.isFetching && !searchQuery.isError && ( | ||||
|           <Stack gap='md'> | ||||
|             <Accordion | ||||
|               multiple | ||||
|               defaultValue={searchQueries.map((q) => q.model)} | ||||
|             > | ||||
|               {queryResults.map((query, idx) => ( | ||||
|                 <QueryResultGroup | ||||
|                   key={idx} | ||||
|                   searchText={searchText} | ||||
|                   query={query} | ||||
|                   navigate={navigate} | ||||
|                   onClose={closeDrawer} | ||||
|                   onRemove={(query) => removeResults(query)} | ||||
|                   onResultClick={(query, pk, event) => | ||||
|                     onResultClick(query, pk, event) | ||||
|                   } | ||||
|                 /> | ||||
|               ))} | ||||
|             </Accordion> | ||||
|           </Stack> | ||||
|         )} | ||||
|         {searchQuery.isError && ( | ||||
|   | ||||
| @@ -37,7 +37,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   partparametertemplate: { | ||||
|     label: () => t`Part Parameter Template`, | ||||
|     label_multiple: () => t`Part Parameter Templates`, | ||||
|     url_overview: '/partparametertemplate', | ||||
|     url_detail: '/partparametertemplate/:pk/', | ||||
|     api_endpoint: ApiEndpoints.part_parameter_template_list, | ||||
|     icon: 'test_templates' | ||||
| @@ -45,7 +44,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   parttesttemplate: { | ||||
|     label: () => t`Part Test Template`, | ||||
|     label_multiple: () => t`Part Test Templates`, | ||||
|     url_overview: '/parttesttemplate', | ||||
|     url_detail: '/parttesttemplate/:pk/', | ||||
|     api_endpoint: ApiEndpoints.part_test_template_list, | ||||
|     icon: 'test' | ||||
| @@ -53,7 +51,7 @@ export const ModelInformationDict: ModelDict = { | ||||
|   supplierpart: { | ||||
|     label: () => t`Supplier Part`, | ||||
|     label_multiple: () => t`Supplier Parts`, | ||||
|     url_overview: '/supplierpart', | ||||
|     url_overview: '/purchasing/index/supplier-parts', | ||||
|     url_detail: '/purchasing/supplier-part/:pk/', | ||||
|     api_endpoint: ApiEndpoints.supplier_part_list, | ||||
|     admin_url: '/company/supplierpart/', | ||||
| @@ -62,7 +60,7 @@ export const ModelInformationDict: ModelDict = { | ||||
|   manufacturerpart: { | ||||
|     label: () => t`Manufacturer Part`, | ||||
|     label_multiple: () => t`Manufacturer Parts`, | ||||
|     url_overview: '/manufacturerpart', | ||||
|     url_overview: '/purchasing/index/manufacturer-parts', | ||||
|     url_detail: '/purchasing/manufacturer-part/:pk/', | ||||
|     api_endpoint: ApiEndpoints.manufacturer_part_list, | ||||
|     admin_url: '/company/manufacturerpart/', | ||||
| @@ -133,7 +131,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   company: { | ||||
|     label: () => t`Company`, | ||||
|     label_multiple: () => t`Companies`, | ||||
|     url_overview: '/company', | ||||
|     url_detail: '/company/:pk/', | ||||
|     api_endpoint: ApiEndpoints.company_list, | ||||
|     admin_url: '/company/company/', | ||||
| @@ -142,7 +139,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   projectcode: { | ||||
|     label: () => t`Project Code`, | ||||
|     label_multiple: () => t`Project Codes`, | ||||
|     url_overview: '/project-code', | ||||
|     url_detail: '/project-code/:pk/', | ||||
|     api_endpoint: ApiEndpoints.project_code_list, | ||||
|     icon: 'list_details' | ||||
| @@ -174,7 +170,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   salesordershipment: { | ||||
|     label: () => t`Sales Order Shipment`, | ||||
|     label_multiple: () => t`Sales Order Shipments`, | ||||
|     url_overview: '/sales/shipment/', | ||||
|     url_detail: '/sales/shipment/:pk/', | ||||
|     api_endpoint: ApiEndpoints.sales_order_shipment_list, | ||||
|     icon: 'sales_orders' | ||||
| @@ -197,7 +192,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   address: { | ||||
|     label: () => t`Address`, | ||||
|     label_multiple: () => t`Addresses`, | ||||
|     url_overview: '/address', | ||||
|     url_detail: '/address/:pk/', | ||||
|     api_endpoint: ApiEndpoints.address_list, | ||||
|     icon: 'address' | ||||
| @@ -205,7 +199,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   contact: { | ||||
|     label: () => t`Contact`, | ||||
|     label_multiple: () => t`Contacts`, | ||||
|     url_overview: '/contact', | ||||
|     url_detail: '/contact/:pk/', | ||||
|     api_endpoint: ApiEndpoints.contact_list, | ||||
|     icon: 'group' | ||||
| @@ -213,7 +206,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   owner: { | ||||
|     label: () => t`Owner`, | ||||
|     label_multiple: () => t`Owners`, | ||||
|     url_overview: '/owner', | ||||
|     url_detail: '/owner/:pk/', | ||||
|     api_endpoint: ApiEndpoints.owner_list, | ||||
|     icon: 'group' | ||||
| @@ -221,7 +213,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   user: { | ||||
|     label: () => t`User`, | ||||
|     label_multiple: () => t`Users`, | ||||
|     url_overview: '/user', | ||||
|     url_detail: '/user/:pk/', | ||||
|     api_endpoint: ApiEndpoints.user_list, | ||||
|     icon: 'user' | ||||
| @@ -229,7 +220,6 @@ export const ModelInformationDict: ModelDict = { | ||||
|   group: { | ||||
|     label: () => t`Group`, | ||||
|     label_multiple: () => t`Groups`, | ||||
|     url_overview: '/user/group', | ||||
|     url_detail: '/user/group-:pk', | ||||
|     api_endpoint: ApiEndpoints.group_list, | ||||
|     admin_url: '/auth/group/', | ||||
| @@ -238,7 +228,7 @@ export const ModelInformationDict: ModelDict = { | ||||
|   importsession: { | ||||
|     label: () => t`Import Session`, | ||||
|     label_multiple: () => t`Import Sessions`, | ||||
|     url_overview: '/import', | ||||
|     url_overview: '/settings/admin/import', | ||||
|     url_detail: '/import/:pk/', | ||||
|     api_endpoint: ApiEndpoints.import_session_list, | ||||
|     icon: 'import' | ||||
| @@ -246,24 +236,24 @@ export const ModelInformationDict: ModelDict = { | ||||
|   labeltemplate: { | ||||
|     label: () => t`Label Template`, | ||||
|     label_multiple: () => t`Label Templates`, | ||||
|     url_overview: '/labeltemplate', | ||||
|     url_detail: '/labeltemplate/:pk/', | ||||
|     url_overview: '/settings/admin/labels', | ||||
|     url_detail: '/settings/admin/labels/:pk/', | ||||
|     api_endpoint: ApiEndpoints.label_list, | ||||
|     icon: 'labels' | ||||
|   }, | ||||
|   reporttemplate: { | ||||
|     label: () => t`Report Template`, | ||||
|     label_multiple: () => t`Report Templates`, | ||||
|     url_overview: '/reporttemplate', | ||||
|     url_detail: '/reporttemplate/:pk/', | ||||
|     url_overview: '/settings/admin/reports', | ||||
|     url_detail: '/settings/admin/reports/:pk/', | ||||
|     api_endpoint: ApiEndpoints.report_list, | ||||
|     icon: 'reports' | ||||
|   }, | ||||
|   pluginconfig: { | ||||
|     label: () => t`Plugin Configuration`, | ||||
|     label_multiple: () => t`Plugin Configurations`, | ||||
|     url_overview: '/pluginconfig', | ||||
|     url_detail: '/pluginconfig/:pk/', | ||||
|     url_overview: '/settings/admin/plugin', | ||||
|     url_detail: '/settings/admin/plugin/:pk/', | ||||
|     api_endpoint: ApiEndpoints.plugin_list, | ||||
|     icon: 'plugin' | ||||
|   }, | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Grid, Skeleton, Stack } from '@mantine/core'; | ||||
| import { | ||||
|   IconBuildingFactory2, | ||||
|   IconBuildingWarehouse, | ||||
|   IconInfoCircle, | ||||
|   IconMap2, | ||||
| @@ -176,24 +175,24 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) { | ||||
|         icon: <IconInfoCircle />, | ||||
|         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', | ||||
|         label: t`Supplied Parts`, | ||||
|         icon: <IconBuildingWarehouse />, | ||||
|         icon: <IconPackageExport />, | ||||
|         hidden: !company?.is_supplier, | ||||
|         content: 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', | ||||
|         label: t`Purchase Orders`, | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import { Stack } from '@mantine/core'; | ||||
| import { | ||||
|   IconBuildingFactory2, | ||||
|   IconBuildingStore, | ||||
|   IconBuildingWarehouse, | ||||
|   IconPackageExport, | ||||
|   IconShoppingCart | ||||
| } from '@tabler/icons-react'; | ||||
| import { useMemo } from 'react'; | ||||
| @@ -13,7 +15,9 @@ import { PanelGroup } from '../../components/panels/PanelGroup'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| import { useUserState } from '../../states/UserState'; | ||||
| import { CompanyTable } from '../../tables/company/CompanyTable'; | ||||
| import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable'; | ||||
| import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; | ||||
| import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; | ||||
|  | ||||
| export default function PurchasingIndex() { | ||||
|   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', | ||||
|         label: t`Manufacturers`, | ||||
| @@ -48,6 +58,12 @@ export default function PurchasingIndex() { | ||||
|             params={{ is_manufacturer: true }} | ||||
|           /> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         name: 'manufacturer-parts', | ||||
|         label: t`Manufacturer Parts`, | ||||
|         icon: <IconBuildingWarehouse />, | ||||
|         content: <ManufacturerPartTable params={{}} /> | ||||
|       } | ||||
|     ]; | ||||
|   }, [user]); | ||||
|   | ||||
| @@ -36,7 +36,7 @@ export default function PurchasingIndex() { | ||||
|         hidden: !user.hasViewRole(UserRoles.return_order) | ||||
|       }, | ||||
|       { | ||||
|         name: 'suppliers', | ||||
|         name: 'customers', | ||||
|         label: t`Customers`, | ||||
|         icon: <IconBuildingStore />, | ||||
|         content: ( | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import { | ||||
|   IconRefresh, | ||||
|   IconTrash | ||||
| } from '@tabler/icons-react'; | ||||
| import { useState } from 'react'; | ||||
| import { useMemo, useState } from 'react'; | ||||
| import { Fragment } from 'react/jsx-runtime'; | ||||
|  | ||||
| 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 ( | ||||
|     <> | ||||
|       {deleteRecords.modal} | ||||
| @@ -135,7 +147,7 @@ export default function InvenTreeTableHeader({ | ||||
|           /> | ||||
|         </Boundary> | ||||
|       )} | ||||
|       {tableState.queryFilters.size > 0 && ( | ||||
|       {(hasCustomFilters || hasCustomSearch) && ( | ||||
|         <Alert | ||||
|           color='yellow' | ||||
|           withCloseButton | ||||
| @@ -143,7 +155,6 @@ export default function InvenTreeTableHeader({ | ||||
|           onClose={() => tableState.clearQueryFilters()} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <Group justify='apart' grow wrap='nowrap'> | ||||
|         <Group justify='left' key='custom-actions' gap={5} wrap='nowrap'> | ||||
|           <PrintingActions | ||||
| @@ -180,6 +191,7 @@ export default function InvenTreeTableHeader({ | ||||
|         <Group justify='right' gap={5} wrap='nowrap'> | ||||
|           {tableProps.enableSearch && ( | ||||
|             <TableSearchInput | ||||
|               disabled={hasCustomSearch} | ||||
|               searchCallback={(term: string) => tableState.setSearchTerm(term)} | ||||
|             /> | ||||
|           )} | ||||
| @@ -208,6 +220,7 @@ export default function InvenTreeTableHeader({ | ||||
|               disabled={tableState.activeFilters?.length == 0} | ||||
|             > | ||||
|               <ActionIcon | ||||
|                 disabled={hasCustomFilters} | ||||
|                 variant='transparent' | ||||
|                 aria-label='table-select-filters' | ||||
|               > | ||||
|   | ||||
| @@ -5,8 +5,10 @@ import { IconSearch } from '@tabler/icons-react'; | ||||
| import { useEffect, useState } from 'react'; | ||||
|  | ||||
| export function TableSearchInput({ | ||||
|   disabled, | ||||
|   searchCallback | ||||
| }: Readonly<{ | ||||
|   disabled?: boolean; | ||||
|   searchCallback: (searchTerm: string) => void; | ||||
| }>) { | ||||
|   const [value, setValue] = useState<string>(''); | ||||
| @@ -19,6 +21,7 @@ export function TableSearchInput({ | ||||
|   return ( | ||||
|     <TextInput | ||||
|       value={value} | ||||
|       disabled={disabled} | ||||
|       leftSection={<IconSearch />} | ||||
|       placeholder={t`Search`} | ||||
|       onChange={(event) => setValue(event.target.value)} | ||||
|   | ||||
| @@ -73,7 +73,7 @@ export function SupplierPartTable({ | ||||
|       DescriptionColumn({}), | ||||
|       { | ||||
|         accessor: 'manufacturer', | ||||
|  | ||||
|         title: t`Manufacturer`, | ||||
|         sortable: true, | ||||
|         render: (record: any) => { | ||||
|           const manufacturer = record?.manufacturer_detail ?? {}; | ||||
|   | ||||
| @@ -97,4 +97,5 @@ export const globalSearch = async (page, query) => { | ||||
|   await page.getByLabel('global-search-input').clear(); | ||||
|   await page.getByPlaceholder('Enter search text').fill(query); | ||||
|   await page.waitForTimeout(300); | ||||
|   await page.waitForLoadState('networkidle'); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										40
									
								
								src/frontend/tests/pages/pui_company.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/frontend/tests/pages/pui_company.spec.ts
									
									
									
									
									
										Normal 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(); | ||||
| }); | ||||
| @@ -1,44 +1,7 @@ | ||||
| import { test } from './baseFixtures.js'; | ||||
| import { navigate } from './helpers.js'; | ||||
| import { globalSearch, 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(); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * 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('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(); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user