mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-09 00:38:50 +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:
parent
09cdf72bda
commit
44cca7ddf2
src
backend/InvenTree/InvenTree
frontend
src
components
pages
tables
tests
@ -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,50 +60,102 @@ 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 />
|
||||
<ActionIcon
|
||||
size='sm'
|
||||
color='red'
|
||||
variant='transparent'
|
||||
radius='xs'
|
||||
aria-label={`remove-search-group-${query.model}`}
|
||||
onClick={() => onRemove(query.model)}
|
||||
>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
<Group justify='right' wrap='nowrap'>
|
||||
<Tooltip label={t`Remove search group`} position='top-end'>
|
||||
<ActionIcon
|
||||
size='sm'
|
||||
color='red'
|
||||
variant='transparent'
|
||||
radius='xs'
|
||||
aria-label={`remove-search-group-${query.model}`}
|
||||
onClick={() => onRemove(query.model)}
|
||||
>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space />
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider />
|
||||
</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'>
|
||||
{queryResults.map((query, idx) => (
|
||||
<QueryResultGroup
|
||||
key={idx}
|
||||
query={query}
|
||||
onRemove={(query) => removeResults(query)}
|
||||
onResultClick={(query, pk, event) =>
|
||||
onResultClick(query, pk, event)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<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();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user