mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-14 16:29:57 +00:00
[UI] Barcode form inputs (#10973)
* Add barcode buttons to related fields - Only field types which support barcodes * Add per-user settings for barcode support * Fill form field with scanned data * Updated docs * Fix duplicate setting * Add playwright tests * Fix duplicate setting in docs * Fix broken link * Fix memo deps * Fix typo * Remove setting * Updated playwright tests * Improved typing
This commit is contained in:
BIN
docs/docs/assets/images/barcode/barcode_field.png
Normal file
BIN
docs/docs/assets/images/barcode/barcode_field.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/docs/assets/images/barcode/barcode_field_dialog.png
Normal file
BIN
docs/docs/assets/images/barcode/barcode_field_dialog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/docs/assets/images/barcode/barcode_field_filled.png
Normal file
BIN
docs/docs/assets/images/barcode/barcode_field_filled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -69,6 +69,19 @@ To access this page, select *Scan Barcode* from the main navigation menu:
|
|||||||
{{ image("barcode/barcode_nav_menu.png", "Barcode menu item") }}
|
{{ image("barcode/barcode_nav_menu.png", "Barcode menu item") }}
|
||||||
{{ image("barcode/barcode_scan_page.png", "Barcode scan page") }}
|
{{ image("barcode/barcode_scan_page.png", "Barcode scan page") }}
|
||||||
|
|
||||||
|
### Barcodes in Forms
|
||||||
|
|
||||||
|
The InvenTree user interface supports direct scanning of barcodes within certain forms in the web UI. This means that any form field which points to a model which supports barcodes can accept barcode input. If barcode scanning is supported for a particular field, a barcode icon will be displayed next to the input field:
|
||||||
|
|
||||||
|
{{ image("barcode/barcode_field.png", "Barcode form field") }}
|
||||||
|
|
||||||
|
To scan a barcode into a form field, click this barcode icon. A barcode scanning dialog will be displayed, allowing the user to scan a barcode using their preferred input method:
|
||||||
|
|
||||||
|
{{ image("barcode/barcode_field_dialog.png", "Barcode field scan dialog") }}
|
||||||
|
|
||||||
|
Once scanned, the form field will be automatically populated with the correct item.
|
||||||
|
|
||||||
|
{{ image("barcode/barcode_field_filled.png", "Barcode field populated") }}
|
||||||
|
|
||||||
## App Integration
|
## App Integration
|
||||||
|
|
||||||
@@ -81,3 +94,10 @@ If enabled, InvenTree can retain logs of the most recent barcode scans. This can
|
|||||||
Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging.
|
Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging.
|
||||||
|
|
||||||
The barcode history can be viewed via the admin panel in the web interface.
|
The barcode history can be viewed via the admin panel in the web interface.
|
||||||
|
|
||||||
|
## Barcode Settings
|
||||||
|
|
||||||
|
There are a number of settings which control the behavior of barcodes within InvenTree. For more information, refer to the links below:
|
||||||
|
|
||||||
|
- [Global Barcode Settings](../settings/global.md#barcodes)
|
||||||
|
- [User Preferences for Barcode Scanning](../settings/user.md#display-settings)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ If this setting is enabled, users can reset their password via email. This requi
|
|||||||
|
|
||||||
If this setting is enabled, users must have multi-factor authentication enabled to log in.
|
If this setting is enabled, users must have multi-factor authentication enabled to log in.
|
||||||
|
|
||||||
#### Auto Fil SSO Users
|
#### Auto Fill SSO Users
|
||||||
|
|
||||||
Automatically fill out user-details from SSO account-data. If this feature is enabled the user is only asked for their username, first- and surname if those values can not be gathered from their SSO profile. This might lead to unwanted usernames bleeding over.
|
Automatically fill out user-details from SSO account-data. If this feature is enabled the user is only asked for their username, first- and surname if those values can not be gathered from their SSO profile. This might lead to unwanted usernames bleeding over.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ The *Display Settings* screen shows general display configuration options:
|
|||||||
{{ usersetting("STICKY_HEADER") }}
|
{{ usersetting("STICKY_HEADER") }}
|
||||||
{{ usersetting("STICKY_TABLE_HEADER") }}
|
{{ usersetting("STICKY_TABLE_HEADER") }}
|
||||||
{{ usersetting("SHOW_SPOTLIGHT") }}
|
{{ usersetting("SHOW_SPOTLIGHT") }}
|
||||||
|
{{ usersetting("BARCODE_IN_FORM_FIELDS") }}
|
||||||
{{ usersetting("DATE_DISPLAY_FORMAT") }}
|
{{ usersetting("DATE_DISPLAY_FORMAT") }}
|
||||||
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
|
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
|
||||||
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
|
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
'BARCODE_IN_FORM_FIELDS': {
|
||||||
|
'name': _('Barcode Scanner in Form Fields'),
|
||||||
|
'description': _('Allow barcode scanner input in form fields'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
'SEARCH_PREVIEW_SHOW_PARTS': {
|
'SEARCH_PREVIEW_SHOW_PARTS': {
|
||||||
'name': _('Search Parts'),
|
'name': _('Search Parts'),
|
||||||
'description': _('Display parts in search preview window'),
|
'description': _('Display parts in search preview window'),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ModelInformationInterface {
|
|||||||
url_detail?: string;
|
url_detail?: string;
|
||||||
api_endpoint: ApiEndpoints;
|
api_endpoint: ApiEndpoints;
|
||||||
admin_url?: string;
|
admin_url?: string;
|
||||||
|
supports_barcode?: boolean;
|
||||||
icon: keyof InvenTreeIconType;
|
icon: keyof InvenTreeIconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/part/:pk/',
|
url_detail: '/part/:pk/',
|
||||||
api_endpoint: ApiEndpoints.part_list,
|
api_endpoint: ApiEndpoints.part_list,
|
||||||
admin_url: '/part/part/',
|
admin_url: '/part/part/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'part'
|
icon: 'part'
|
||||||
},
|
},
|
||||||
parameter: {
|
parameter: {
|
||||||
@@ -60,6 +62,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
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/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'supplier_part'
|
icon: 'supplier_part'
|
||||||
},
|
},
|
||||||
manufacturerpart: {
|
manufacturerpart: {
|
||||||
@@ -69,6 +72,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
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/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'manufacturers'
|
icon: 'manufacturers'
|
||||||
},
|
},
|
||||||
partcategory: {
|
partcategory: {
|
||||||
@@ -87,6 +91,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/stock/item/:pk/',
|
url_detail: '/stock/item/:pk/',
|
||||||
api_endpoint: ApiEndpoints.stock_item_list,
|
api_endpoint: ApiEndpoints.stock_item_list,
|
||||||
admin_url: '/stock/stockitem/',
|
admin_url: '/stock/stockitem/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'stock'
|
icon: 'stock'
|
||||||
},
|
},
|
||||||
stocklocation: {
|
stocklocation: {
|
||||||
@@ -96,6 +101,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/stock/location/:pk/',
|
url_detail: '/stock/location/:pk/',
|
||||||
api_endpoint: ApiEndpoints.stock_location_list,
|
api_endpoint: ApiEndpoints.stock_location_list,
|
||||||
admin_url: '/stock/stocklocation/',
|
admin_url: '/stock/stocklocation/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'location'
|
icon: 'location'
|
||||||
},
|
},
|
||||||
stocklocationtype: {
|
stocklocationtype: {
|
||||||
@@ -117,6 +123,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/manufacturing/build-order/:pk/',
|
url_detail: '/manufacturing/build-order/:pk/',
|
||||||
api_endpoint: ApiEndpoints.build_order_list,
|
api_endpoint: ApiEndpoints.build_order_list,
|
||||||
admin_url: '/build/build/',
|
admin_url: '/build/build/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'build_order'
|
icon: 'build_order'
|
||||||
},
|
},
|
||||||
buildline: {
|
buildline: {
|
||||||
@@ -155,6 +162,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/purchasing/purchase-order/:pk/',
|
url_detail: '/purchasing/purchase-order/:pk/',
|
||||||
api_endpoint: ApiEndpoints.purchase_order_list,
|
api_endpoint: ApiEndpoints.purchase_order_list,
|
||||||
admin_url: '/order/purchaseorder/',
|
admin_url: '/order/purchaseorder/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'purchase_orders'
|
icon: 'purchase_orders'
|
||||||
},
|
},
|
||||||
purchaseorderlineitem: {
|
purchaseorderlineitem: {
|
||||||
@@ -170,6 +178,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/sales/sales-order/:pk/',
|
url_detail: '/sales/sales-order/:pk/',
|
||||||
api_endpoint: ApiEndpoints.sales_order_list,
|
api_endpoint: ApiEndpoints.sales_order_list,
|
||||||
admin_url: '/order/salesorder/',
|
admin_url: '/order/salesorder/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'sales_orders'
|
icon: 'sales_orders'
|
||||||
},
|
},
|
||||||
salesordershipment: {
|
salesordershipment: {
|
||||||
@@ -178,6 +187,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_overview: '/sales/index/shipments',
|
url_overview: '/sales/index/shipments',
|
||||||
url_detail: '/sales/shipment/:pk/',
|
url_detail: '/sales/shipment/:pk/',
|
||||||
api_endpoint: ApiEndpoints.sales_order_shipment_list,
|
api_endpoint: ApiEndpoints.sales_order_shipment_list,
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'shipment'
|
icon: 'shipment'
|
||||||
},
|
},
|
||||||
returnorder: {
|
returnorder: {
|
||||||
@@ -187,6 +197,7 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/sales/return-order/:pk/',
|
url_detail: '/sales/return-order/:pk/',
|
||||||
api_endpoint: ApiEndpoints.return_order_list,
|
api_endpoint: ApiEndpoints.return_order_list,
|
||||||
admin_url: '/order/returnorder/',
|
admin_url: '/order/returnorder/',
|
||||||
|
supports_barcode: true,
|
||||||
icon: 'return_orders'
|
icon: 'return_orders'
|
||||||
},
|
},
|
||||||
returnorderlineitem: {
|
returnorderlineitem: {
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export type BarcodeScanResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BarcodeScanSuccessCallback = (
|
||||||
|
barcode: string,
|
||||||
|
response: any
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Callback function for handling a barcode scan
|
// Callback function for handling a barcode scan
|
||||||
// This function should return true if the barcode was handled successfully
|
// This function should return true if the barcode was handled successfully
|
||||||
export type BarcodeScanCallback = (
|
export type BarcodeScanCallback = (
|
||||||
@@ -31,13 +36,15 @@ export default function BarcodeScanDialog({
|
|||||||
opened,
|
opened,
|
||||||
callback,
|
callback,
|
||||||
modelType,
|
modelType,
|
||||||
onClose
|
onClose,
|
||||||
|
onScanSuccess
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
title?: string;
|
title?: string;
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
modelType?: ModelType;
|
modelType?: ModelType;
|
||||||
callback?: BarcodeScanCallback;
|
callback?: BarcodeScanCallback;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onScanSuccess?: BarcodeScanSuccessCallback;
|
||||||
}>) {
|
}>) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -53,6 +60,7 @@ export default function BarcodeScanDialog({
|
|||||||
<ScanInputHandler
|
<ScanInputHandler
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onScanSuccess={onScanSuccess}
|
||||||
modelType={modelType}
|
modelType={modelType}
|
||||||
callback={callback}
|
callback={callback}
|
||||||
/>
|
/>
|
||||||
@@ -65,10 +73,12 @@ export function ScanInputHandler({
|
|||||||
callback,
|
callback,
|
||||||
modelType,
|
modelType,
|
||||||
onClose,
|
onClose,
|
||||||
|
onScanSuccess,
|
||||||
navigate
|
navigate
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
callback?: BarcodeScanCallback;
|
callback?: BarcodeScanCallback;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onScanSuccess?: BarcodeScanSuccessCallback;
|
||||||
modelType?: ModelType;
|
modelType?: ModelType;
|
||||||
navigate: NavigateFunction;
|
navigate: NavigateFunction;
|
||||||
}>) {
|
}>) {
|
||||||
@@ -94,7 +104,13 @@ export function ScanInputHandler({
|
|||||||
data[model_type]['pk']
|
data[model_type]['pk']
|
||||||
);
|
);
|
||||||
onClose();
|
onClose();
|
||||||
navigate(url);
|
|
||||||
|
if (onScanSuccess) {
|
||||||
|
onScanSuccess(data['barcode'], data);
|
||||||
|
} else {
|
||||||
|
navigate(url);
|
||||||
|
}
|
||||||
|
|
||||||
match = true;
|
match = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
|
import type { ModelType } from '@lib/index';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconQrcode } from '@tabler/icons-react';
|
import { IconQrcode } from '@tabler/icons-react';
|
||||||
import BarcodeScanDialog from '../barcodes/BarcodeScanDialog';
|
import BarcodeScanDialog, {
|
||||||
|
type BarcodeScanCallback,
|
||||||
|
type BarcodeScanSuccessCallback
|
||||||
|
} from '../barcodes/BarcodeScanDialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A button which opens the QR code scanner modal
|
* A button which opens the QR code scanner modal
|
||||||
*/
|
*/
|
||||||
export function ScanButton() {
|
export function ScanButton({
|
||||||
|
modelType,
|
||||||
|
callback,
|
||||||
|
onScanSuccess
|
||||||
|
}: {
|
||||||
|
modelType?: ModelType;
|
||||||
|
callback?: BarcodeScanCallback;
|
||||||
|
onScanSuccess?: BarcodeScanSuccessCallback;
|
||||||
|
}) {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
|
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
variant='transparent'
|
variant='transparent'
|
||||||
title={t`Open Barcode Scanner`}
|
title={t`Open Barcode Scanner`}
|
||||||
@@ -21,7 +34,13 @@ export function ScanButton() {
|
|||||||
<IconQrcode />
|
<IconQrcode />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<BarcodeScanDialog opened={opened} onClose={close} />
|
<BarcodeScanDialog
|
||||||
|
opened={opened}
|
||||||
|
modelType={modelType}
|
||||||
|
callback={callback}
|
||||||
|
onClose={close}
|
||||||
|
onScanSuccess={onScanSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import {
|
import {
|
||||||
|
Group,
|
||||||
Input,
|
Input,
|
||||||
darken,
|
darken,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
@@ -15,9 +16,16 @@ import {
|
|||||||
} from 'react-hook-form';
|
} from 'react-hook-form';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
|
|
||||||
|
import { ModelInformationDict } from '@lib/enums/ModelInformation';
|
||||||
import type { ApiFormFieldType } from '@lib/types/Forms';
|
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||||
import { useApi } from '../../../contexts/ApiContext';
|
import { useApi } from '../../../contexts/ApiContext';
|
||||||
|
import {
|
||||||
|
useGlobalSettingsState,
|
||||||
|
useUserSettingsState
|
||||||
|
} from '../../../states/SettingsStates';
|
||||||
import { vars } from '../../../theme';
|
import { vars } from '../../../theme';
|
||||||
|
import { ScanButton } from '../../buttons/ScanButton';
|
||||||
|
import Expand from '../../items/Expand';
|
||||||
import { RenderInstance } from '../../render/Instance';
|
import { RenderInstance } from '../../render/Instance';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +68,101 @@ export function RelatedModelField({
|
|||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const dataRef = useRef<any[]>([]);
|
const dataRef = useRef<any[]>([]);
|
||||||
|
|
||||||
|
const globalSettings = useGlobalSettingsState();
|
||||||
|
const userSettings = useUserSettingsState();
|
||||||
|
|
||||||
|
// Search input query
|
||||||
|
const [value, setValue] = useState<string>('');
|
||||||
|
const [searchText] = useDebouncedValue(value, 250);
|
||||||
|
|
||||||
|
// Fetch a single field by primary key, using the provided API filters
|
||||||
|
const fetchSingleField = useCallback(
|
||||||
|
(pk: number) => {
|
||||||
|
if (!definition?.api_url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = definition?.filters ?? {};
|
||||||
|
const url = `${definition.api_url}${pk}/`;
|
||||||
|
|
||||||
|
api
|
||||||
|
.get(url, {
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const pk_field = definition.pk_field ?? 'pk';
|
||||||
|
if (response.data?.[pk_field]) {
|
||||||
|
const value = {
|
||||||
|
value: response.data[pk_field],
|
||||||
|
data: response.data
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run custom callback for this field (if provided)
|
||||||
|
if (definition.onValueChange) {
|
||||||
|
definition.onValueChange(response.data[pk_field], response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialData(value);
|
||||||
|
dataRef.current = [value];
|
||||||
|
setPk(response.data[pk_field]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
definition.api_url,
|
||||||
|
definition.filters,
|
||||||
|
definition.onValueChange,
|
||||||
|
definition.pk_field,
|
||||||
|
setValue,
|
||||||
|
setPk
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the model type information for this field
|
||||||
|
const modelInfo = useMemo(() => {
|
||||||
|
if (!definition.model) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ModelInformationDict[definition.model];
|
||||||
|
}, [definition.model]);
|
||||||
|
|
||||||
|
// Determine whether a barcode field should be added
|
||||||
|
const addBarcodeField: boolean = useMemo(() => {
|
||||||
|
if (!modelInfo || !modelInfo.supports_barcode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalSettings.isSet('BARCODE_ENABLE')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [globalSettings, userSettings, modelInfo]);
|
||||||
|
|
||||||
|
// Callback function to handle barcode scan results
|
||||||
|
const onBarcodeScan = useCallback(
|
||||||
|
(barcode: string, response: any) => {
|
||||||
|
// Fetch model information from the response
|
||||||
|
const modelData = response?.[definition.model ?? ''] ?? null;
|
||||||
|
|
||||||
|
if (modelData) {
|
||||||
|
const pk_field = definition.pk_field ?? 'pk';
|
||||||
|
const pk = modelData[pk_field];
|
||||||
|
|
||||||
|
if (pk) {
|
||||||
|
// Perform a full re-fetch of the field data
|
||||||
|
// This is necessary as the barcode scan does not provide full data necessarily
|
||||||
|
fetchSingleField(pk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[definition.model, definition.pk_field, fetchSingleField]
|
||||||
|
);
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const [autoFilled, setAutoFilled] = useState<boolean>(false);
|
const [autoFilled, setAutoFilled] = useState<boolean>(false);
|
||||||
@@ -144,37 +247,7 @@ export function RelatedModelField({
|
|||||||
const id = pk || field.value;
|
const id = pk || field.value;
|
||||||
|
|
||||||
if (id !== null && id !== undefined && id !== '') {
|
if (id !== null && id !== undefined && id !== '') {
|
||||||
const url = `${definition.api_url}${id}/`;
|
fetchSingleField(id);
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
setPk(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = definition?.filters ?? {};
|
|
||||||
|
|
||||||
api
|
|
||||||
.get(url, {
|
|
||||||
params: params
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
const pk_field = definition.pk_field ?? 'pk';
|
|
||||||
if (response.data?.[pk_field]) {
|
|
||||||
const value = {
|
|
||||||
value: response.data[pk_field],
|
|
||||||
data: response.data
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run custom callback for this field (if provided)
|
|
||||||
if (definition.onValueChange) {
|
|
||||||
definition.onValueChange(response.data[pk_field], response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialData(value);
|
|
||||||
dataRef.current = [value];
|
|
||||||
setPk(response.data[pk_field]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setPk(null);
|
setPk(null);
|
||||||
}
|
}
|
||||||
@@ -185,10 +258,6 @@ export function RelatedModelField({
|
|||||||
field.value
|
field.value
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Search input query
|
|
||||||
const [value, setValue] = useState<string>('');
|
|
||||||
const [searchText] = useDebouncedValue(value, 250);
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState<any>({});
|
const [filters, setFilters] = useState<any>({});
|
||||||
|
|
||||||
const resetSearch = useCallback(() => {
|
const resetSearch = useCallback(() => {
|
||||||
@@ -377,58 +446,68 @@ export function RelatedModelField({
|
|||||||
error={definition.error ?? error?.message}
|
error={definition.error ?? error?.message}
|
||||||
styles={{ description: { paddingBottom: '5px' } }}
|
styles={{ description: { paddingBottom: '5px' } }}
|
||||||
>
|
>
|
||||||
<Select
|
<Group justify='space-between' wrap='nowrap' gap={3}>
|
||||||
id={fieldId}
|
<Expand>
|
||||||
aria-label={`related-field-${field.name}`}
|
<Select
|
||||||
value={currentValue}
|
id={fieldId}
|
||||||
ref={field.ref}
|
aria-label={`related-field-${field.name}`}
|
||||||
options={data}
|
value={currentValue}
|
||||||
filterOption={null}
|
ref={field.ref}
|
||||||
onInputChange={(value: any) => {
|
options={data}
|
||||||
setValue(value);
|
filterOption={null}
|
||||||
}}
|
onInputChange={(value: any) => {
|
||||||
onChange={onChange}
|
setValue(value);
|
||||||
onMenuScrollToBottom={() => setOffset(offset + limit)}
|
}}
|
||||||
onMenuOpen={() => {
|
onChange={onChange}
|
||||||
setIsOpen(true);
|
onMenuScrollToBottom={() => setOffset(offset + limit)}
|
||||||
resetSearch();
|
onMenuOpen={() => {
|
||||||
selectQuery.refetch();
|
setIsOpen(true);
|
||||||
}}
|
resetSearch();
|
||||||
onMenuClose={() => {
|
selectQuery.refetch();
|
||||||
setIsOpen(false);
|
}}
|
||||||
}}
|
onMenuClose={() => {
|
||||||
isLoading={
|
setIsOpen(false);
|
||||||
selectQuery.isFetching ||
|
}}
|
||||||
selectQuery.isLoading ||
|
isLoading={
|
||||||
selectQuery.isRefetching
|
selectQuery.isFetching ||
|
||||||
}
|
selectQuery.isLoading ||
|
||||||
isClearable={!definition.required}
|
selectQuery.isRefetching
|
||||||
isDisabled={definition.disabled}
|
|
||||||
isSearchable={true}
|
|
||||||
placeholder={definition.placeholder || `${t`Search`}...`}
|
|
||||||
loadingMessage={() => `${t`Loading`}...`}
|
|
||||||
menuPortalTarget={document.body}
|
|
||||||
noOptionsMessage={() => t`No results found`}
|
|
||||||
menuPosition='fixed'
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
|
|
||||||
clearIndicator: (base: any) => ({
|
|
||||||
...base,
|
|
||||||
color: 'red',
|
|
||||||
':hover': { color: 'red' }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
formatOptionLabel={(option: any) => formatOption(option)}
|
|
||||||
theme={(theme) => {
|
|
||||||
return {
|
|
||||||
...theme,
|
|
||||||
colors: {
|
|
||||||
...theme.colors,
|
|
||||||
...colors
|
|
||||||
}
|
}
|
||||||
};
|
isClearable={!definition.required}
|
||||||
}}
|
isDisabled={definition.disabled}
|
||||||
/>
|
isSearchable={true}
|
||||||
|
placeholder={definition.placeholder || `${t`Search`}...`}
|
||||||
|
loadingMessage={() => `${t`Loading`}...`}
|
||||||
|
menuPortalTarget={document.body}
|
||||||
|
noOptionsMessage={() => t`No results found`}
|
||||||
|
menuPosition='fixed'
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||||
|
clearIndicator: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
color: 'red',
|
||||||
|
':hover': { color: 'red' }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
formatOptionLabel={(option: any) => formatOption(option)}
|
||||||
|
theme={(theme) => {
|
||||||
|
return {
|
||||||
|
...theme,
|
||||||
|
colors: {
|
||||||
|
...theme.colors,
|
||||||
|
...colors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Expand>
|
||||||
|
{addBarcodeField && (
|
||||||
|
<ScanButton
|
||||||
|
modelType={definition.model}
|
||||||
|
onScanSuccess={onBarcodeScan}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default function UserSettings() {
|
|||||||
'STICKY_HEADER',
|
'STICKY_HEADER',
|
||||||
'STICKY_TABLE_HEADER',
|
'STICKY_TABLE_HEADER',
|
||||||
'SHOW_SPOTLIGHT',
|
'SHOW_SPOTLIGHT',
|
||||||
|
'BARCODE_IN_FORM_FIELDS',
|
||||||
'DATE_DISPLAY_FORMAT',
|
'DATE_DISPLAY_FORMAT',
|
||||||
'FORMS_CLOSE_USING_ESCAPE',
|
'FORMS_CLOSE_USING_ESCAPE',
|
||||||
'DISPLAY_STOCKTAKE_TAB',
|
'DISPLAY_STOCKTAKE_TAB',
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ export const clearTableFilters = async (page: Page) => {
|
|||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setTableChoiceFilter = async (page: Page, filter, value) => {
|
export const setTableChoiceFilter = async (
|
||||||
|
page: Page,
|
||||||
|
filter: string,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
await openFilterDrawer(page);
|
await openFilterDrawer(page);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||||
@@ -116,7 +120,7 @@ export const navigate = async (
|
|||||||
/**
|
/**
|
||||||
* CLick on the 'tab' element with the provided name
|
* CLick on the 'tab' element with the provided name
|
||||||
*/
|
*/
|
||||||
export const loadTab = async (page: Page, tabName, exact?) => {
|
export const loadTab = async (page: Page, tabName: string, exact?: boolean) => {
|
||||||
await page
|
await page
|
||||||
.getByLabel(/panel-tabs-/)
|
.getByLabel(/panel-tabs-/)
|
||||||
.getByRole('tab', { name: tabName, exact: exact ?? false })
|
.getByRole('tab', { name: tabName, exact: exact ?? false })
|
||||||
@@ -140,7 +144,7 @@ export const activateCalendarView = async (page: Page) => {
|
|||||||
/**
|
/**
|
||||||
* Perform a 'global search' on the provided page, for the provided query text
|
* Perform a 'global search' on the provided page, for the provided query text
|
||||||
*/
|
*/
|
||||||
export const globalSearch = async (page: Page, query) => {
|
export const globalSearch = async (page: Page, query: string) => {
|
||||||
await page.getByLabel('open-search').click();
|
await page.getByLabel('open-search').click();
|
||||||
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);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface LoginOptions {
|
|||||||
/*
|
/*
|
||||||
* Perform form based login operation from the "login" URL
|
* Perform form based login operation from the "login" URL
|
||||||
*/
|
*/
|
||||||
export const doLogin = async (page, options?: LoginOptions) => {
|
export const doLogin = async (page: Page, options?: LoginOptions) => {
|
||||||
const username: string = options?.username ?? user.username;
|
const username: string = options?.username ?? user.username;
|
||||||
const password: string = options?.password ?? user.password;
|
const password: string = options?.password ?? user.password;
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,9 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
|
|||||||
// Ensure we can scan back to this page, with the associated barcode
|
// Ensure we can scan back to this page, with the associated barcode
|
||||||
await page.getByRole('tab', { name: 'Sales' }).click();
|
await page.getByRole('tab', { name: 'Sales' }).click();
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
|
||||||
|
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
|
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
|
||||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,37 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
import { test } from '../baseFixtures';
|
import { test } from '../baseFixtures';
|
||||||
import { doCachedLogin } from '../login';
|
import { doCachedLogin } from '../login';
|
||||||
|
|
||||||
const scan = async (page, barcode) => {
|
const scan = async (page: Page, barcode: string) => {
|
||||||
await page.getByLabel('barcode-input-scanner').click();
|
await page.getByLabel('barcode-input-scanner').click();
|
||||||
await page.getByLabel('barcode-scan-keyboard-input').fill(barcode);
|
await page.getByLabel('barcode-scan-keyboard-input').fill(barcode);
|
||||||
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
test('Scanning - Dialog', async ({ browser }) => {
|
test('Barcode Scanning - Dialog', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser);
|
const page = await doCachedLogin(browser);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
// Attempt scan with invalid data
|
||||||
|
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
|
||||||
|
await scan(page, 'invalid-barcode-123');
|
||||||
|
await page.getByText('No match found for barcode').waitFor();
|
||||||
|
|
||||||
|
// Attempt scan with "legacy" barcode format
|
||||||
await scan(page, '{"part": 15}');
|
await scan(page, '{"part": 15}');
|
||||||
|
|
||||||
await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
|
await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
|
||||||
await page.getByText('Available:').waitFor();
|
await page.getByText('Available:').waitFor();
|
||||||
await page.getByText('Required:').waitFor();
|
await page.getByText('Required:').waitFor();
|
||||||
|
|
||||||
|
// Attempt scan with "modern" barcode format
|
||||||
|
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
|
||||||
|
await scan(page, 'INV-BO0010');
|
||||||
|
|
||||||
|
await page.getByText('Build Order: BO0010').waitFor();
|
||||||
|
await page.getByText('Making a high level assembly part').waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - Basic', async ({ browser }) => {
|
test('Barcode Scanning - Basic', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser);
|
const page = await doCachedLogin(browser);
|
||||||
|
|
||||||
// Navigate to the 'scan' page
|
// Navigate to the 'scan' page
|
||||||
@@ -39,7 +52,7 @@ test('Scanning - Basic', async ({ browser }) => {
|
|||||||
await page.getByText('No match found for barcode').waitFor();
|
await page.getByText('No match found for barcode').waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - Part', async ({ browser }) => {
|
test('Barcode Scanning - Part', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
|
|
||||||
await scan(page, '{"part": 1}');
|
await scan(page, '{"part": 1}');
|
||||||
@@ -49,7 +62,7 @@ test('Scanning - Part', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'part', exact: true }).waitFor();
|
await page.getByRole('cell', { name: 'part', exact: true }).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - Stockitem', async ({ browser }) => {
|
test('Barcode Scanning - Stockitem', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
await scan(page, '{"stockitem": 408}');
|
await scan(page, '{"stockitem": 408}');
|
||||||
|
|
||||||
@@ -58,7 +71,7 @@ test('Scanning - Stockitem', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
|
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - StockLocation', async ({ browser }) => {
|
test('Barcode Scanning - StockLocation', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
|
|
||||||
await scan(page, '{"stocklocation": 3}');
|
await scan(page, '{"stocklocation": 3}');
|
||||||
@@ -71,7 +84,7 @@ test('Scanning - StockLocation', async ({ browser }) => {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - SupplierPart', async ({ browser }) => {
|
test('Barcode Scanning - SupplierPart', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
await scan(page, '{"supplierpart": 204}');
|
await scan(page, '{"supplierpart": 204}');
|
||||||
|
|
||||||
@@ -80,7 +93,7 @@ test('Scanning - SupplierPart', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor();
|
await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - PurchaseOrder', async ({ browser }) => {
|
test('Barcode Scanning - PurchaseOrder', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
await scan(page, '{"purchaseorder": 12}');
|
await scan(page, '{"purchaseorder": 12}');
|
||||||
|
|
||||||
@@ -92,7 +105,7 @@ test('Scanning - PurchaseOrder', async ({ browser }) => {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - SalesOrder', async ({ browser }) => {
|
test('Barcode Scanning - SalesOrder', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
|
|
||||||
await scan(page, '{"salesorder": 6}');
|
await scan(page, '{"salesorder": 6}');
|
||||||
@@ -103,7 +116,7 @@ test('Scanning - SalesOrder', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor();
|
await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scanning - Build', async ({ browser }) => {
|
test('Barcode Scanning - Build', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'scan/' });
|
const page = await doCachedLogin(browser, { url: 'scan/' });
|
||||||
await scan(page, '{"build": 8}');
|
await scan(page, '{"build": 8}');
|
||||||
|
|
||||||
@@ -112,3 +125,32 @@ test('Scanning - Build', async ({ browser }) => {
|
|||||||
await page.getByText('PCBA build').waitFor();
|
await page.getByText('PCBA build').waitFor();
|
||||||
await page.getByRole('cell', { name: 'build', exact: true }).waitFor();
|
await page.getByRole('cell', { name: 'build', exact: true }).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Barcode Scanning - Forms', async ({ browser }) => {
|
||||||
|
const page = await doCachedLogin(browser, {
|
||||||
|
url: '/stock/location/index/stock-items'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the "Add Stock Item" form
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'action-button-add-stock-item' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Fill out the "part" data
|
||||||
|
await page.getByRole('button', { name: 'barcode-scan-button-part' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
|
||||||
|
.fill('INV-PA99');
|
||||||
|
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||||
|
await page.getByText('Red Round Table').waitFor();
|
||||||
|
|
||||||
|
// Fill out the "location" data
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
|
||||||
|
.fill('INV-SL37');
|
||||||
|
await page.getByRole('button', { name: 'Scan', exact: true }).click();
|
||||||
|
await page.getByText('Offsite Storage').waitFor();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user