2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Barcode scanning (#8732)

* Implement new "general purpose" barcode scan dialog

- Separated widgets for camera / keyboard / wedge scanner
- UI / UX improvements

* Handle scan results

* Fix missing imports

* Handle successful global scan

* Handle error when linking barcode

* Backend fix for InvenTreeInternalBarcodePlugin

* Error handling

* Working on scanner input

* Refactor scan page

* Callback from scanner input

* Refactoring <Scan> page

* Allow InvenTreeTable to be used with supplied data

* Refactor optionalparams

* Refactoring table of scan results

* Implement callbacks

* Navigate from barcode table

* Fix delete callback

* Refactor callbacks

* Refactor idAccessor

- Access as part of useTable hook
- No longer hard-coded to 'pk'

* prevent duplicate scans

* Fix for deleting items from table

* Cleanup

* Bump API version

* Adjust playwright tests

* Update playwright tests

* Update barcode screenshots

* Fix links

* Add quick links to barcode formats

* Updated screenshots

* Fix for BuildLineSubTable

* Specify idAccessor values

* Clear barcode input after timeout period

* Move items

* Fix for playwright test

* Remove debug print

* Additional error ignores

* Cleanup scanner input

- Simplify
- Prevent errant keycodes from closing the scanner dialog

* Playwright test adjustments
This commit is contained in:
Oliver 2024-12-28 20:38:53 +11:00 committed by GitHub
parent 0765b00520
commit 3e73162368
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1204 additions and 1141 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -13,7 +13,15 @@ InvenTree has native support for barcodes, which provides powerful functionality
- Barcodes can be embedded in [labels or reports](../report/barcodes.md) - Barcodes can be embedded in [labels or reports](../report/barcodes.md)
- Barcode functionality can be [extended via plugins](../extend/plugins/barcode.md) - Barcode functionality can be [extended via plugins](../extend/plugins/barcode.md)
### Barcode Data Types ### Barcode Formats
InvenTree supports the following barcode formats:
- [Internal Barcodes](./internal.md): Native InvenTree barcodes, which are automatically generated for each item
- [External Barcodes](./external.md): External (third party) barcodes which can be assigned to items
- [Custom Barcodes](./custom.md): Fully customizable barcodes can be generated using the plugin system.
### Barcode Model Linking
Barcodes can be linked with the following data model types: Barcodes can be linked with the following data model types:
@ -21,41 +29,58 @@ Barcodes can be linked with the following data model types:
- [Stock Item](../stock/stock.md#stock-item) - [Stock Item](../stock/stock.md#stock-item)
- [Stock Location](../stock/stock.md#stock-location) - [Stock Location](../stock/stock.md#stock-location)
- [Supplier Part](../order/company.md#supplier-parts) - [Supplier Part](../order/company.md#supplier-parts)
- [Purchase Order](../order/purchase_order.md#purchase-orders)
- [Sales Order](../order/sales_order.md#sales-orders)
- [Return Order](../order/return_order.md#return-orders)
- [Build Order](../build/build.md#build-orders)
### Configuration Options
The barcode system can be configured via the [global settings](../settings/global.md#barcodes).
## Web Integration ## Web Integration
Barcode scanning can be enabled within the web interface. Barcode scanning in the web interface supports scanning via: Barcode scanning can be enabled within the web interface. This allows users to scan barcodes directly from the web browser.
- Keyboard style scanners (e.g. USB connected) ### Input Modes
- Webcam (image processing)
### Configuration The following barcode input modes are supported by the web interface:
Barcode scanning may need to be enabled for the web interface: - **Webcam**: Use a connected webcam to scan barcodes
- **Scanner**: Use a connected barcode scanner to scan barcodes
- **Keyboard**: Manually enter a barcode via the keyboard
{% with id="barcode_config", url="barcode/barcode_settings.png", description="Barcode settings" %} ### Quick Scan
{% include 'img.html' %}
{% endwith %}
### Scanning If barcode scanning is enabled in the web interface, select the barcode icon in the top-right of the menu bar to perform a quick-scan of a barcode. If the barcode is recognized by the system, the web browser will automatically navigate to the correct item:
When enabled, select the barcode icon in the top-right of the menu bar to scan a barcode. If the barcode is recognized by the system, the web browser will automatically navigate to the correct item:
{% with id="barcode_scan", url="barcode/barcode_scan.png", description="Barcode scan" %} {% with id="barcode_scan", url="barcode/barcode_scan.png", description="Barcode scan" %}
{% include 'img.html' %} {% include 'img.html' %}
{% endwith %} {% endwith %}
#### No Match Found
If no match is found for the scanned barcode, the following error message is displayed: If no match is found for the scanned barcode, the following error message is displayed:
{% with id="barcode_no_match", url="barcode/barcode_no_match.png", description="No match for barcode" %} {% with id="barcode_no_match", url="barcode/barcode_no_match.png", description="No match for barcode" %}
{% include 'img.html' %} {% include 'img.html' %}
{% endwith %} {% endwith %}
### Scanning Action Page
A more comprehensive barcode scanning interface is available via the "Scan" page in the web interface. This page allows the user to scan multiple barcodes, and perform certain actions on the scanned items.
To access this page, select *Scan Barcode* from the main navigation menu:
{% with id="barcode_nav_menu", url="barcode/barcode_nav_menu.png", description="Barcode menu item" %}
{% include 'img.html' %}
{% endwith %}
{% with id="barcode_scan_page", url="barcode/barcode_scan_page.png", description="Barcode scan page" %}
{% include 'img.html' %}
{% endwith %}
## App Integration ## App Integration
Barcode scanning is a key feature of the [companion mobile app](../app/barcode.md). Barcode scanning is a key feature of the [companion mobile app](../app/barcode.md). When running on a device with an integrated camera, the app can scan barcodes directly from the camera feed.
## Barcode History ## Barcode History

View File

@ -36,7 +36,11 @@ To link an arbitrary barcode, select the *Link Barcode* action as shown below:
If an item already has a linked barcode, it can be un-linked by selecting the *Unlink Barcode* action: If an item already has a linked barcode, it can be un-linked by selecting the *Unlink Barcode* action:
{% with id="barcode_unlink", url="barcode/barcode_unlink.png", description="Unlink barcode" %} {% with id="barcode_unlink_1", url="barcode/barcode_unlink_1.png", description="Unlink barcode" %}
{% include 'img.html' %}
{% endwith %}
{% with id="barcode_unlink_2", url="barcode/barcode_unlink_2.png", description="Unlink barcode" %}
{% include 'img.html' %} {% include 'img.html' %}
{% endwith %} {% endwith %}

View File

@ -91,6 +91,8 @@ Configuration of barcode functionality:
{{ globalsetting("BARCODE_STORE_RESULTS") }} {{ globalsetting("BARCODE_STORE_RESULTS") }}
{{ globalsetting("BARCODE_RESULTS_MAX_NUM") }} {{ globalsetting("BARCODE_RESULTS_MAX_NUM") }}
Read more about [barcode scanning](../barcodes/barcodes.md).
### Pricing and Currency ### Pricing and Currency
Configuration of pricing data and currency support: Configuration of pricing data and currency support:

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 295 INVENTREE_API_VERSION = 296
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
- Adjust default "part_detail" behaviour for StockItem API endpoints
v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746 v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746
- Improve API documentation for build APIs - Improve API documentation for build APIs

View File

@ -135,15 +135,15 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi
def generate(self, model_instance: InvenTreeBarcodeMixin): def generate(self, model_instance: InvenTreeBarcodeMixin):
"""Generate a barcode for a given model instance.""" """Generate a barcode for a given model instance."""
barcode_format = self.get_setting('INTERNAL_BARCODE_FORMAT') barcode_format = self.get_setting(
'INTERNAL_BARCODE_FORMAT', backup_value='json'
if barcode_format == 'json': )
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
if barcode_format == 'short': if barcode_format == 'short':
prefix = self.get_setting('SHORT_BARCODE_PREFIX') prefix = self.get_setting('SHORT_BARCODE_PREFIX')
model_type_code = model_instance.barcode_model_type_code() model_type_code = model_instance.barcode_model_type_code()
return f'{prefix}{model_type_code}{model_instance.pk}' return f'{prefix}{model_type_code}{model_instance.pk}'
else:
return None # Default = JSON format
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})

View File

@ -902,8 +902,9 @@ class StockApiMixin:
try: try:
params = self.request.query_params params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
for key in [ for key in [
'part_detail',
'path_detail', 'path_detail',
'location_detail', 'location_detail',
'supplier_part_detail', 'supplier_part_detail',

View File

@ -11,8 +11,8 @@ export const api = axios.create({});
* Setup default settings for the Axios API instance. * Setup default settings for the Axios API instance.
*/ */
export function setApiDefaults() { export function setApiDefaults() {
const host = useLocalState.getState().host; const { host } = useLocalState.getState();
const token = useUserState.getState().token; const { token } = useUserState.getState();
api.defaults.baseURL = host; api.defaults.baseURL = host;
api.defaults.timeout = 2500; api.defaults.timeout = 2500;

View File

@ -0,0 +1,207 @@
import { t } from '@lingui/macro';
import { ActionIcon, Container, Group, Select, Stack } from '@mantine/core';
import { useDocumentVisibility, useLocalStorage } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
IconCamera,
IconPlayerPlayFilled,
IconPlayerStopFilled,
IconX
} from '@tabler/icons-react';
import { type CameraDevice, Html5Qrcode } from 'html5-qrcode';
import { useEffect, useState } from 'react';
import Expand from '../items/Expand';
import type { BarcodeInputProps } from './BarcodeInput';
export default function BarcodeCameraInput({
onScan
}: Readonly<BarcodeInputProps>) {
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
key: 'camId',
defaultValue: null
});
const [cameras, setCameras] = useState<any[]>([]);
const [cameraValue, setCameraValue] = useState<string | null>(null);
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
const documentState = useDocumentVisibility();
let lastValue = '';
// Mount QR code once we are loaded
useEffect(() => {
setQrCodeScanner(new Html5Qrcode('reader'));
// load cameras
Html5Qrcode.getCameras().then((devices) => {
if (devices?.length) {
setCameras(devices);
}
});
}, []);
// set camera value from id
useEffect(() => {
if (camId) {
setCameraValue(camId.id);
}
}, [camId]);
// Stop/start when leaving or reentering page
useEffect(() => {
if (scanningEnabled && documentState === 'hidden') {
btnStopScanning();
setWasAutoPaused(true);
} else if (wasAutoPaused && documentState === 'visible') {
btnStartScanning();
setWasAutoPaused(false);
}
}, [documentState]);
// Scanner functions
function onScanSuccess(decodedText: string) {
qrCodeScanner?.pause();
// dedouplication
if (decodedText === lastValue) {
qrCodeScanner?.resume();
return;
}
lastValue = decodedText;
// submit value upstream
onScan?.(decodedText);
qrCodeScanner?.resume();
}
function onScanFailure(error: string) {
if (
error !=
'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.'
) {
console.warn(`Code scan error = ${error}`);
}
}
function btnStartScanning() {
if (camId && qrCodeScanner && !scanningEnabled) {
qrCodeScanner
.start(
camId.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
onScanSuccess(decodedText);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
)
.catch((err: string) => {
showNotification({
title: t`Error while scanning`,
message: err,
color: 'red',
icon: <IconX />
});
});
setScanningEnabled(true);
}
}
function btnStopScanning() {
if (qrCodeScanner && scanningEnabled) {
qrCodeScanner.stop().catch((err: string) => {
showNotification({
title: t`Error while stopping`,
message: err,
color: 'red',
icon: <IconX />
});
});
setScanningEnabled(false);
}
}
// on value change
useEffect(() => {
if (cameraValue === null) return;
if (cameraValue === camId?.id) {
return;
}
const cam = cameras.find((cam) => cam.id === cameraValue);
// stop scanning if cam changed while scanning
if (qrCodeScanner && scanningEnabled) {
// stop scanning
qrCodeScanner.stop().then(() => {
// change ID
setCamId(cam);
// start scanning
qrCodeScanner.start(
cam.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
onScanSuccess(decodedText);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
);
});
} else {
setCamId(cam);
}
}, [cameraValue]);
const placeholder = t`Start scanning by selecting a camera and pressing the play button.`;
return (
<Stack gap='xs'>
<Group gap='xs' preventGrowOverflow>
<Expand>
<Select
leftSection={<IconCamera />}
value={cameraValue}
onChange={setCameraValue}
data={cameras.map((device) => {
return { value: device.id, label: device.label };
})}
/>
</Expand>
{scanningEnabled ? (
<ActionIcon
size='lg'
color='red'
onClick={btnStopScanning}
title={t`Stop scanning`}
variant='transparent'
>
<IconPlayerStopFilled />
</ActionIcon>
) : (
<ActionIcon
size='lg'
color='green'
onClick={btnStartScanning}
title={t`Start scanning`}
disabled={!camId}
variant='transparent'
>
<IconPlayerPlayFilled />
</ActionIcon>
)}
</Group>
{scanningEnabled ? (
<Container px={0} id='reader' w={'100%'} mih='300px' />
) : (
<Container px={0} id='reader' w={'100%'}>
{placeholder}
</Container>
)}
</Stack>
);
}

View File

@ -0,0 +1,123 @@
import { t } from '@lingui/macro';
import {
Alert,
Box,
Card,
Divider,
LoadingOverlay,
SegmentedControl,
type SegmentedControlItem,
Stack,
Tooltip
} from '@mantine/core';
import { IconCamera, IconScan } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { Boundary } from '../Boundary';
import BarcodeCameraInput from './BarcodeCameraInput';
import BarcodeKeyboardInput from './BarcodeKeyboardInput';
export type BarcodeInputProps = {
onScan: (barcode: string) => void;
processing?: boolean;
error?: string;
label?: string;
actionText?: string;
};
export function BarcodeInput({
onScan,
error,
processing,
label = t`Barcode`,
actionText = t`Scan`
}: Readonly<BarcodeInputProps>) {
const [barcode, setBarcode] = useState<string>('');
const [inputType, setInputType] = useLocalStorage<string | null>({
key: 'barcodeInputType',
defaultValue: 'scanner'
});
const scanningOptions: SegmentedControlItem[] = useMemo(() => {
const options: SegmentedControlItem[] = [];
// TODO : Hide camera input optionally
options.push({
value: 'camera',
label: (
<Tooltip label={t`Camera Input`}>
<IconCamera size={20} aria-label='barcode-input-camera' />
</Tooltip>
)
});
options.push({
value: 'scanner',
label: (
<Tooltip label={t`Scanner Input`}>
<IconScan size={20} aria-label='barcode-input-scanner' />
</Tooltip>
)
});
return options;
}, []);
const onScanBarcode = useCallback(
(barcode: string) => {
setBarcode(barcode);
onScan(barcode);
},
[onScan]
);
const scannerInput = useMemo(() => {
switch (inputType) {
case 'camera':
return <BarcodeCameraInput onScan={onScanBarcode} />;
case 'scanner':
default:
return <BarcodeKeyboardInput onScan={onScanBarcode} />;
}
}, [inputType, onScan]);
return (
<Box>
<Boundary label='BarcodeInput'>
<LoadingOverlay visible={processing} />
<Stack gap='xs'>
<SegmentedControl
aria-label='barcode-input-type'
size='xs'
data={scanningOptions}
value={inputType || 'scanner'}
onChange={setInputType}
/>
<Divider />
<Card p='sm' withBorder>
{barcode ? (
<Alert color='blue' title={t`Barcode Data`} p='xs'>
{barcode}
</Alert>
) : (
<Alert color='yellow' title={t`No barcode data`} p='xs'>
{t`Scan or enter barcode data`}
</Alert>
)}
{error && (
<Alert color='red' title={t`Error`} p='xs'>
{error}
</Alert>
)}
</Card>
<Card p='sm' withBorder>
{scannerInput}
</Card>
</Stack>
</Boundary>
</Box>
);
}

View File

@ -0,0 +1,50 @@
import { t } from '@lingui/macro';
import { Button, FocusTrap, Stack, TextInput } from '@mantine/core';
import { IconQrcode } from '@tabler/icons-react';
import { useCallback, useState } from 'react';
import type { BarcodeInputProps } from './BarcodeInput';
export default function BarcodeKeyboardInput({
onScan,
actionText = t`Scan`
}: Readonly<BarcodeInputProps>) {
const [text, setText] = useState<string>('');
const onTextScan = useCallback(
(barcode: string) => {
if (!!barcode) {
onScan(barcode);
}
setText('');
},
[onScan]
);
return (
<>
<Stack gap='sm'>
<FocusTrap active>
<TextInput
data-autofocus
aria-label='barcode-scan-keyboard-input'
value={text}
onChange={(event) => {
setText(event.currentTarget?.value);
}}
onKeyDown={(event) => {
if (event.code === 'Enter') {
onTextScan(text);
}
}}
placeholder={t`Enter barcode data`}
leftSection={<IconQrcode />}
w='100%'
/>
</FocusTrap>
<Button fullWidth disabled={!text} onClick={() => onTextScan(text)}>
{actionText}
</Button>
</Stack>
</>
);
}

View File

@ -0,0 +1,98 @@
import { t } from '@lingui/macro';
import { Box, Divider, Modal } from '@mantine/core';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType';
import { extractErrorMessage } from '../../functions/api';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { StylishText } from '../items/StylishText';
import { ModelInformationDict } from '../render/ModelType';
import { BarcodeInput } from './BarcodeInput';
export default function BarcodeScanDialog({
title,
opened,
onClose
}: {
title?: string;
opened: boolean;
onClose: () => void;
}) {
const navigate = useNavigate();
const user = useUserState();
const [error, setError] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const onScan = useCallback((barcode: string) => {
if (!barcode || barcode.length === 0) {
return;
}
setProcessing(true);
api
.post(apiUrl(ApiEndpoints.barcode), {
barcode: barcode
})
.then((response) => {
setError('');
const data = response.data ?? {};
let match = false;
// Find the matching model type
for (const model_type of Object.keys(ModelInformationDict)) {
if (data[model_type]?.['pk']) {
if (user.hasViewPermission(model_type as ModelType)) {
const url = getDetailUrl(
model_type as ModelType,
data[model_type]['pk']
);
onClose();
navigate(url);
match = true;
break;
}
}
}
if (!match) {
setError(t`No matching item found`);
}
})
.catch((error) => {
const _error = extractErrorMessage({
error: error,
field: 'error',
defaultMessage: t`Failed to scan barcode`
});
setError(_error);
})
.finally(() => {
setProcessing(false);
});
}, []);
return (
<>
<Modal
size='lg'
opened={opened}
onClose={onClose}
title={<StylishText size='xl'>{title ?? t`Scan Barcode`}</StylishText>}
>
<Divider />
<Box>
<BarcodeInput onScan={onScan} error={error} processing={processing} />
</Box>
</Modal>
</>
);
}

View File

@ -0,0 +1,23 @@
import type { ModelType } from '../../enums/ModelType';
/**
* Interface defining a single barcode scan item
* @param id: Unique identifier for the scan
* @param barcode: Scanned barcode data
* @param data: Data returned from the server
* @param instance: Instance of the scanned item (if discovered)
* @param timestamp: Date and time of the scan
* @param source: Source of the scan (e.g. 'barcode', 'QR code')
* @param model: Model type of the scanned item
* @param pk: Primary key of the scanned item
*/
export interface BarcodeScanItem {
id: string;
barcode: string;
data?: any;
instance?: any;
timestamp: Date;
source: string;
model?: ModelType;
pk?: string;
}

View File

@ -15,14 +15,16 @@ import {
import { modals } from '@mantine/modals'; import { modals } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import QR from 'qrcode'; import QR from 'qrcode';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState'; import { useGlobalSettingsState } from '../../states/SettingsState';
import { CopyButton } from '../buttons/CopyButton'; import { CopyButton } from '../buttons/CopyButton';
import type { QrCodeType } from './ActionDropdown'; import type { QrCodeType } from '../items/ActionDropdown';
import { extractErrorMessage } from '../../functions/api';
import { BarcodeInput } from './BarcodeInput'; import { BarcodeInput } from './BarcodeInput';
type QRCodeProps = { type QRCodeProps = {
@ -76,12 +78,13 @@ export const InvenTreeQRCode = ({
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk], queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
queryFn: async () => { queryFn: async () => {
const res = await api.post(apiUrl(ApiEndpoints.barcode_generate), { return api
model: mdl_prop.model, .post(apiUrl(ApiEndpoints.barcode_generate), {
pk: mdl_prop.pk model: mdl_prop.model,
}); pk: mdl_prop.pk
})
return res.data?.barcode as string; .then((res) => res.data?.barcode ?? ('' as string))
.catch((error) => '');
} }
}); });
@ -146,37 +149,33 @@ export const InvenTreeQRCode = ({
}; };
export const QRCodeLink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => { export const QRCodeLink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
const [barcode, setBarcode] = useState(''); const [error, setError] = useState<string>('');
function linkBarcode(value?: string) { const linkBarcode = useCallback((barcode: string) => {
api api
.post(apiUrl(ApiEndpoints.barcode_link), { .post(apiUrl(ApiEndpoints.barcode_link), {
[mdl_prop.model]: mdl_prop.pk, [mdl_prop.model]: mdl_prop.pk,
barcode: value || barcode barcode: barcode
}) })
.then((response) => { .then((response) => {
setError('');
modals.closeAll(); modals.closeAll();
location.reload(); location.reload();
})
.catch((error) => {
const msg = extractErrorMessage({
error: error,
field: 'error',
defaultMessage: t`Failed to link barcode`
});
setError(msg);
}); });
} }, []);
const actionSubmit = (decodedText: string) => {
linkBarcode(decodedText);
};
const handleLinkBarcode = () => {
linkBarcode(barcode);
};
return ( return (
<Stack gap='xs'> <Stack gap='xs'>
<Divider /> <Divider />
<BarcodeInput <BarcodeInput onScan={linkBarcode} actionText={t`Link`} error={error} />
value={barcode}
onChange={(event) => setBarcode(event.currentTarget.value)}
onScan={actionSubmit}
onAction={handleLinkBarcode}
actionText={t`Link`}
/>
</Stack> </Stack>
); );
}; };
@ -194,11 +193,12 @@ export const QRCodeUnlink = ({ mdl_prop }: { mdl_prop: QrCodeType }) => {
} }
return ( return (
<Box> <Box>
<Stack gap='xs'> <Stack gap='sm'>
<Divider /> <Divider />
<Text> <Text>
<Trans>This will remove the link to the associated barcode</Trans> <Trans>This will remove the link to the associated barcode</Trans>
</Text> </Text>
<Divider />
<Group grow> <Group grow>
<Button color='red' onClick={unlinkBarcode}> <Button color='red' onClick={unlinkBarcode}>
<Trans>Unlink Barcode</Trans> <Trans>Unlink Barcode</Trans>

View File

@ -1,25 +1,25 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ActionIcon } from '@mantine/core'; import { ActionIcon } from '@mantine/core';
import { openContextModal } from '@mantine/modals'; import { useDisclosure } from '@mantine/hooks';
import { IconQrcode } from '@tabler/icons-react'; import { IconQrcode } from '@tabler/icons-react';
import BarcodeScanDialog 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() {
const [opened, { open, close }] = useDisclosure(false);
return ( return (
<ActionIcon <>
onClick={() => <ActionIcon
openContextModal({ onClick={open}
modal: 'qr', variant='transparent'
title: t`Scan Barcode`, title={t`Open Barcode Scanner`}
innerProps: {} >
}) <IconQrcode />
} </ActionIcon>
variant='transparent' <BarcodeScanDialog opened={opened} onClose={close} />
title={t`Open Barcode Scanner`} </>
>
<IconQrcode />
</ActionIcon>
); );
} }

View File

@ -22,7 +22,8 @@ import { type ReactNode, useMemo } from 'react';
import type { ModelType } from '../../enums/ModelType'; import type { ModelType } from '../../enums/ModelType';
import { identifierString } from '../../functions/conversion'; import { identifierString } from '../../functions/conversion';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { InvenTreeQRCode, QRCodeLink, QRCodeUnlink } from './QRCode'; import { InvenTreeQRCode, QRCodeLink, QRCodeUnlink } from '../barcodes/QRCode';
import { StylishText } from './StylishText';
export type ActionDropdownItem = { export type ActionDropdownItem = {
icon?: ReactNode; icon?: ReactNode;
@ -213,7 +214,7 @@ function GeneralBarcodeAction({
}): ActionDropdownItem { }): ActionDropdownItem {
const onClick = () => { const onClick = () => {
modals.open({ modals.open({
title: title, title: <StylishText size='xl'>{title}</StylishText>,
children: <ChildItem mdl_prop={mdl_prop} /> children: <ChildItem mdl_prop={mdl_prop} />
}); });
}; };

View File

@ -1,60 +0,0 @@
import { t } from '@lingui/macro';
import { ActionIcon, Box, Button, Divider, TextInput } from '@mantine/core';
import { IconQrcode } from '@tabler/icons-react';
import type React from 'react';
import { useState } from 'react';
import { InputImageBarcode } from '../../pages/Index/Scan';
type BarcodeInputProps = {
onScan: (decodedText: string) => void;
value?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onAction?: () => void;
placeholder?: string;
label?: string;
actionText?: string;
};
export function BarcodeInput({
onScan,
value,
onChange,
onAction,
placeholder = t`Scan barcode data here using barcode scanner`,
label = t`Barcode`,
actionText = t`Scan`
}: Readonly<BarcodeInputProps>) {
const [isScanning, setIsScanning] = useState(false);
return (
<Box>
{isScanning && (
<>
<InputImageBarcode action={onScan} />
<Divider mt={'sm'} />
</>
)}
<TextInput
label={label}
value={value}
onChange={onChange}
placeholder={placeholder}
leftSection={
<ActionIcon
variant={isScanning ? 'filled' : 'subtle'}
onClick={() => setIsScanning(!isScanning)}
>
<IconQrcode />
</ActionIcon>
}
w='100%'
/>
{onAction ? (
<Button color='green' onClick={onAction} mt='lg' fullWidth>
{actionText}
</Button>
) : null}
</Box>
);
}

View File

@ -1,61 +0,0 @@
import { Trans, t } from '@lingui/macro';
import { Button, ScrollArea, Stack, Text } from '@mantine/core';
import { useListState } from '@mantine/hooks';
import type { ContextModalProps } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { apiUrl } from '../../states/ApiState';
import { BarcodeInput } from '../items/BarcodeInput';
export function QrCodeModal({
context,
id
}: Readonly<ContextModalProps<{ modalBody: string }>>) {
const [values, handlers] = useListState<string>([]);
function onScanAction(decodedText: string) {
handlers.append(decodedText);
api
.post(apiUrl(ApiEndpoints.barcode), { barcode: decodedText })
.then((response) => {
showNotification({
title: response.data?.success || t`Unknown response`,
message: JSON.stringify(response.data),
color: response.data?.success ? 'teal' : 'red'
});
if (response.data?.url) {
window.location.href = response.data.url;
}
});
}
return (
<Stack gap='xs'>
<BarcodeInput onScan={onScanAction} />
{values.length == 0 ? (
<Text c={'grey'}>
<Trans>No scans yet!</Trans>
</Text>
) : (
<ScrollArea style={{ height: 200 }} type='auto' offsetScrollbars>
{values.map((value, index) => (
<div key={`${index}-${value}`}>{value}</div>
))}
</ScrollArea>
)}
<Button
fullWidth
mt='md'
color='red'
onClick={() => {
// stopScanning();
context.closeModal(id);
}}
>
<Trans>Close modal</Trans>
</Button>
</Stack>
);
}

View File

@ -6,6 +6,7 @@ import { type ReactNode, useCallback } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation'; import { navigateToLink } from '../../functions/navigation';
import { shortenString } from '../../functions/tables';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { Thumbnail } from '../images/Thumbnail'; import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build'; import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
@ -193,6 +194,16 @@ export function RenderInlineModel({
[url, navigate] [url, navigate]
); );
const primaryText = shortenString({
str: primary,
len: 50
});
const secondaryText = shortenString({
str: secondary,
len: 75
});
return ( return (
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}> <Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
<Group gap='xs' justify='left' wrap='nowrap'> <Group gap='xs' justify='left' wrap='nowrap'>
@ -200,12 +211,12 @@ export function RenderInlineModel({
{image && <Thumbnail src={image} size={18} />} {image && <Thumbnail src={image} size={18} />}
{url ? ( {url ? (
<Anchor href='' onClick={(event: any) => onClick(event)}> <Anchor href='' onClick={(event: any) => onClick(event)}>
<Text size='sm'>{primary}</Text> <Text size='sm'>{primaryText}</Text>
</Anchor> </Anchor>
) : ( ) : (
<Text size='sm'>{primary}</Text> <Text size='sm'>{primaryText}</Text>
)} )}
{showSecondary && secondary && <Text size='xs'>{secondary}</Text>} {showSecondary && secondary && <Text size='xs'>{secondaryText}</Text>}
</Group> </Group>
{suffix && ( {suffix && (
<> <>

View File

@ -67,7 +67,7 @@ export function RenderStockItem(
<RenderInlineModel <RenderInlineModel
{...props} {...props}
primary={instance.part_detail?.full_name} primary={instance.part_detail?.full_name}
suffix={<Text size='sm'>{quantity_string}</Text>} suffix={<Text size='xs'>{quantity_string}</Text>}
image={instance.part_detail?.thumbnail || instance.part_detail?.image} image={instance.part_detail?.thumbnail || instance.part_detail?.image}
url={ url={
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined

View File

@ -6,7 +6,6 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal'; import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
import { LicenseModal } from '../components/modals/LicenseModal'; import { LicenseModal } from '../components/modals/LicenseModal';
import { QrCodeModal } from '../components/modals/QrCodeModal';
import { ServerInfoModal } from '../components/modals/ServerInfoModal'; import { ServerInfoModal } from '../components/modals/ServerInfoModal';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { LanguageContext } from './LanguageContext'; import { LanguageContext } from './LanguageContext';
@ -46,7 +45,6 @@ export function ThemeContext({
<ModalsProvider <ModalsProvider
labels={{ confirm: t`Submit`, cancel: t`Cancel` }} labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
modals={{ modals={{
qr: QrCodeModal,
info: ServerInfoModal, info: ServerInfoModal,
about: AboutInvenTreeModal, about: AboutInvenTreeModal,
license: LicenseModal license: LicenseModal

View File

@ -0,0 +1,61 @@
import { t } from '@lingui/macro';
/**
* Extract a sensible error message from an API error response
* @param error The error response from the API
* @param field The field to extract the error message from (optional)
* @param defaultMessage A default message to use if no error message is found (optional)
*/
export function extractErrorMessage({
error,
field,
defaultMessage
}: {
error: any;
field?: string;
defaultMessage?: string;
}): string {
const error_data = error.response?.data ?? null;
let message = '';
if (error_data) {
message = error_data[field ?? 'error'] ?? error_data['non_field_errors'];
}
// No message? Look at the response status codes
if (!message) {
const status = error.response?.status ?? null;
if (status) {
switch (status) {
case 400:
message = t`Bad request`;
break;
case 401:
message = t`Unauthorized`;
break;
case 403:
message = t`Forbidden`;
break;
case 404:
message = t`Not found`;
break;
case 405:
message = t`Method not allowed`;
break;
case 500:
message = t`Internal server error`;
break;
default:
break;
}
}
}
if (!message) {
message = defaultMessage ?? t`An error occurred`;
}
return message;
}

View File

@ -1,6 +1,7 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
import { extractErrorMessage } from './api';
/** /**
* Show a notification that the feature is not yet implemented * Show a notification that the feature is not yet implemented
@ -86,19 +87,15 @@ export function showApiErrorMessage({
message?: string; message?: string;
field?: string; field?: string;
}) { }) {
// Extract error description from response const errorMessage = extractErrorMessage({
const error_data: any = error.response?.data ?? {}; error: error,
field: field,
let error_msg: any = defaultMessage: message
message ?? error_data[field ?? 'error'] ?? error_data['non_field_errors']; });
if (!error_msg) {
error_msg = t`An error occurred`;
}
notifications.show({ notifications.show({
title: title, title: title,
message: error_msg, message: errorMessage,
color: 'red' color: 'red'
}); });
} }

View File

@ -38,6 +38,7 @@ import type { TableFilter } from '../tables/Filter';
* records: An array of records (rows) in the table * records: An array of records (rows) in the table
* setRecords: A function to set the records * setRecords: A function to set the records
* updateRecord: A function to update a single record in the table * updateRecord: A function to update a single record in the table
* idAccessor: The name of the primary key field in the records (default = 'pk')
*/ */
export type TableState = { export type TableState = {
tableKey: string; tableKey: string;
@ -54,7 +55,7 @@ export type TableState = {
setExpandedRecords: (records: any[]) => void; setExpandedRecords: (records: any[]) => void;
isRowExpanded: (pk: number) => boolean; isRowExpanded: (pk: number) => boolean;
selectedRecords: any[]; selectedRecords: any[];
selectedIds: number[]; selectedIds: any[];
hasSelectedRecords: boolean; hasSelectedRecords: boolean;
setSelectedRecords: (records: any[]) => void; setSelectedRecords: (records: any[]) => void;
clearSelectedRecords: () => void; clearSelectedRecords: () => void;
@ -71,6 +72,7 @@ export type TableState = {
records: any[]; records: any[];
setRecords: (records: any[]) => void; setRecords: (records: any[]) => void;
updateRecord: (record: any) => void; updateRecord: (record: any) => void;
idAccessor?: string;
}; };
/** /**
@ -79,7 +81,7 @@ export type TableState = {
* Refer to the TableState type definition for more information. * Refer to the TableState type definition for more information.
*/ */
export function useTable(tableName: string): TableState { export function useTable(tableName: string, idAccessor = 'pk'): TableState {
// Function to generate a new ID (to refresh the table) // Function to generate a new ID (to refresh the table)
function generateTableName() { function generateTableName() {
return `${tableName.replaceAll('-', '')}-${randomId()}`; return `${tableName.replaceAll('-', '')}-${randomId()}`;
@ -127,7 +129,7 @@ export function useTable(tableName: string): TableState {
// Array of selected primary key values // Array of selected primary key values
const selectedIds = useMemo( const selectedIds = useMemo(
() => selectedRecords.map((r) => r.pk ?? r.id), () => selectedRecords.map((r) => r[idAccessor || 'pk']),
[selectedRecords] [selectedRecords]
); );
@ -164,7 +166,9 @@ export function useTable(tableName: string): TableState {
const _records = [...records]; const _records = [...records];
// Find the matching record in the table // Find the matching record in the table
const index = _records.findIndex((r) => r.pk === record.pk); const index = _records.findIndex(
(r) => r[idAccessor || 'pk'] === record.pk
);
if (index >= 0) { if (index >= 0) {
_records[index] = { _records[index] = {
@ -213,6 +217,7 @@ export function useTable(tableName: string): TableState {
setPageSize, setPageSize,
records, records,
setRecords, setRecords,
updateRecord updateRecord,
idAccessor
}; };
} }

View File

@ -1,205 +1,134 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { import {
ActionIcon, ActionIcon,
Badge, Alert,
Button, Divider,
Checkbox,
Container,
Grid, Grid,
Group, Group,
ScrollArea, Paper,
Select,
Space, Space,
Stack, Stack,
Table, Text
Text,
TextInput,
rem
} from '@mantine/core'; } from '@mantine/core';
import { import { randomId, useListState, useLocalStorage } from '@mantine/hooks';
getHotkeyHandler,
randomId,
useDocumentVisibility,
useFullscreen,
useListState,
useLocalStorage
} from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { import {
IconAlertCircle, IconAlertCircle,
IconArrowsMaximize,
IconArrowsMinimize,
IconLink,
IconNumber, IconNumber,
IconPlayerPlayFilled, IconQuestionMark
IconPlayerStopFilled,
IconPlus,
IconQuestionMark,
IconSearch,
IconTrash,
IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Html5Qrcode } from 'html5-qrcode'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { CameraDevice } from 'html5-qrcode/camera/core';
import { useEffect, useMemo, useState } from 'react';
import { showNotification } from '@mantine/notifications';
import { api } from '../../App'; import { api } from '../../App';
import { DocInfo } from '../../components/items/DocInfo'; import { BarcodeInput } from '../../components/barcodes/BarcodeInput';
import type { BarcodeScanItem } from '../../components/barcodes/BarcodeScanItem';
import { StylishText } from '../../components/items/StylishText'; import { StylishText } from '../../components/items/StylishText';
import { TitleWithDoc } from '../../components/items/TitleWithDoc';
import PageTitle from '../../components/nav/PageTitle'; import PageTitle from '../../components/nav/PageTitle';
import { RenderInstance } from '../../components/render/Instance';
import { ModelInformationDict } from '../../components/render/ModelType'; import { ModelInformationDict } from '../../components/render/ModelType';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import type { ModelType } from '../../enums/ModelType';
import { notYetImplemented } from '../../functions/notifications'; import {
import { IS_DEV_OR_DEMO } from '../../main'; notYetImplemented,
showApiErrorMessage
} from '../../functions/notifications';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import BarcodeScanTable from '../../tables/general/BarcodeScanTable';
export interface ScanItem {
id: string;
ref: string;
data: any;
instance?: any;
timestamp: Date;
source: string;
link?: string;
model?: ModelType;
pk?: string;
}
/*
* Match the scanned object to a known internal model type
*/
function matchObject(rd: any): [ModelType | undefined, string | undefined] {
if (rd?.part) {
return [ModelType.part, rd?.part.pk];
} else if (rd?.stockitem) {
return [ModelType.stockitem, rd?.stockitem.pk];
} else if (rd?.stocklocation) {
return [ModelType.stocklocation, rd?.stocklocation.pk];
} else if (rd?.supplierpart) {
return [ModelType.supplierpart, rd?.supplierpart.pk];
} else if (rd?.purchaseorder) {
return [ModelType.purchaseorder, rd?.purchaseorder.pk];
} else if (rd?.salesorder) {
return [ModelType.salesorder, rd?.salesorder.pk];
} else if (rd?.build) {
return [ModelType.build, rd?.build.pk];
} else {
return [undefined, undefined];
}
}
export default function Scan() { export default function Scan() {
const { toggle: toggleFullscreen, fullscreen } = useFullscreen(); const [history, historyHandlers] = useListState<BarcodeScanItem>([]);
const [history, historyHandlers] = useListState<ScanItem>([]);
const [historyStorage, setHistoryStorage] = useLocalStorage<ScanItem[]>({ const [historyStorage, setHistoryStorage] = useLocalStorage<
BarcodeScanItem[]
>({
key: 'scan-history', key: 'scan-history',
defaultValue: [] defaultValue: []
}); });
const [selection, setSelection] = useState<string[]>([]); const [selection, setSelection] = useState<string[]>([]);
const [inputValue, setInputValue] = useLocalStorage<string | null>({
key: 'input-selection',
defaultValue: null
});
// button handlers // Fetch model instance based on scan item
function btnRunSelectedBarcode() { const fetchInstance = useCallback(
const item = getSelectedItem(selection[0]); (item: BarcodeScanItem) => {
if (!item) return; if (!item.model || !item.pk) {
runBarcode(item?.ref, item?.id); return;
} }
const selectionLinked = // Prevent duplicates
selection.length === 1 && getSelectedItem(selection[0])?.link != undefined; if (history.find((i) => i.model == item.model && i.pk == item.pk)) {
showNotification({
label: t`Duplicate`,
message: t`Item already scanned`,
color: 'orange'
});
return;
}
function btnOpenSelectedLink() { const model_info = ModelInformationDict[item.model];
const item = getSelectedItem(selection[0]);
if (!item) return;
if (!selectionLinked) return;
window.open(item.link, '_blank');
}
function btnDeleteFullHistory() { api
historyHandlers.setState([]); .get(apiUrl(model_info.api_endpoint, item.pk))
setHistoryStorage([]); .then((response) => {
setSelection([]); item.instance = response.data;
} historyHandlers.append(item);
})
.catch((error) => {
showApiErrorMessage({
error: error,
title: t`API Error`,
message: t`Failed to fetch instance data`
});
});
},
[api, history]
);
function btnDeleteHistory() { // Barcode scanning callback function
historyHandlers.setState( const scanBarcode = useCallback(
history.filter((item) => !selection.includes(item.id)) (barcode: string) => {
); api
setSelection([]); .post(apiUrl(ApiEndpoints.barcode), { barcode: barcode })
} .then((response) => {
const data = response?.data ?? {};
// general functions let match = false;
function getSelectedItem(ref: string): ScanItem | undefined {
if (selection.length === 0) return;
const item = history.find((item) => item.id === ref);
if (item?.ref === undefined) return;
return item;
}
function runBarcode(value: string, id?: string) { for (const model_type of Object.keys(ModelInformationDict)) {
api if (data[model_type]?.pk) {
.post(apiUrl(ApiEndpoints.barcode), { barcode: value }) match = true;
.then((response) => { fetchInstance({
// update item in history id: randomId(),
if (!id) return; barcode: barcode,
const item = getSelectedItem(selection[0]); data: data,
if (!item) return; timestamp: new Date(),
source: 'scan',
// set link data model: model_type as ModelType,
item.link = response.data?.url; pk: data[model_type]?.pk
const rsp = matchObject(response.data);
item.model = rsp[0];
item.pk = rsp[1];
// Fetch instance data
if (item.model && item.pk) {
const model_info = ModelInformationDict[item.model];
if (model_info?.api_endpoint) {
const url = apiUrl(model_info.api_endpoint, item.pk);
api
.get(url)
.then((response) => {
item.instance = response.data;
const list_idx = history.findIndex((i) => i.id === id);
historyHandlers.setItem(list_idx, item);
})
.catch((err) => {
console.error('error while fetching instance data at', url);
console.info(err);
}); });
}
} }
} else {
historyHandlers.setState(history);
}
})
.catch((err) => {
// 400 and no plugin means no match
if (
err.response?.status === 400 &&
err.response?.data?.plugin === 'None'
)
return;
// otherwise log error
console.log('error while running barcode', err);
});
}
function addItems(items: ScanItem[]) { // If no match is found, add an empty result
for (const item of items) { if (!match) {
historyHandlers.append(item); historyHandlers.append({
runBarcode(item.ref, item.id); id: randomId(),
} barcode: barcode,
setSelection(items.map((item) => item.id)); data: data,
} timestamp: new Date(),
source: 'scan'
});
}
})
.catch((error) => {
showApiErrorMessage({
error: error,
message: t`Failed to scan barcode`,
title: t`Scan Error`,
field: 'error'
});
});
},
[fetchInstance]
);
// save history data to session storage // save history data to session storage
useEffect(() => { useEffect(() => {
@ -208,61 +137,27 @@ export default function Scan() {
}, [history]); }, [history]);
// load data from session storage on mount // load data from session storage on mount
if (history.length === 0 && historyStorage.length != 0) { useEffect(() => {
historyHandlers.setState(historyStorage); historyHandlers.setState(historyStorage);
} }, [historyStorage]);
// input stuff // Items selected for action
const inputOptions = [ const selectedItems: BarcodeScanItem[] = useMemo(() => {
{ value: InputMethod.Manual, label: t`Manual input` }, return history.filter((item) => selection.includes(item.id));
{ value: InputMethod.ImageBarcode, label: t`Image Barcode` } }, [selection, history]);
];
const inp = (() => {
switch (inputValue) {
case InputMethod.Manual:
return <InputManual action={addItems} />;
case InputMethod.ImageBarcode:
return (
<InputImageBarcode
action={(decodedText: string) => {
addItems([
{
id: randomId(),
ref: decodedText,
data: decodedText,
timestamp: new Date(),
source: InputMethod.ImageBarcode
}
]);
}}
/>
);
default:
return <Text>No input selected</Text>;
}
})();
// selected actions component // selected actions component
const SelectedActions = () => { const SelectedActions = useMemo(() => {
const uniqueObjectTypes = [ const uniqueObjectTypes = new Set(selectedItems.map((item) => item.model));
...new Set(
selection
.map((id) => {
return history.find((item) => item.id === id)?.model;
})
.filter((item) => item != undefined)
)
];
if (uniqueObjectTypes.length === 0) { if (uniqueObjectTypes.size === 0) {
return ( return (
<Group gap={0}> <Group gap={0}>
<IconQuestionMark color='orange' /> <IconQuestionMark color='orange' />
<Trans>Selected elements are not known</Trans> <Trans>Selected elements are not known</Trans>
</Group> </Group>
); );
} else if (uniqueObjectTypes.length > 1) { } else if (uniqueObjectTypes.size > 1) {
return ( return (
<Group gap={0}> <Group gap={0}>
<IconAlertCircle color='orange' /> <IconAlertCircle color='orange' />
@ -270,10 +165,11 @@ export default function Scan() {
</Group> </Group>
); );
} }
return ( return (
<> <>
<Text fz='sm' c='dimmed'> <Text fz='sm' c='dimmed'>
<Trans>Actions for {uniqueObjectTypes[0]} </Trans> <Trans>Actions ... </Trans>
</Text> </Text>
<Group> <Group>
<ActionIcon <ActionIcon
@ -286,499 +182,72 @@ export default function Scan() {
</Group> </Group>
</> </>
); );
}; }, [selectedItems]);
// rendering
return ( return (
<> <>
<PageTitle title={t`Barcode Scanning`} /> <PageTitle title={t`Barcode Scanning`} />
<Group justify='space-between'> <Group justify='space-between'>
<Group justify='left'> <Group justify='left'>
<StylishText> <StylishText size='xl'>
<Trans>Scan Page</Trans> <Trans>Barcode Scanning</Trans>
</StylishText> </StylishText>
<DocInfo
text={t`This page can be used for continuously scanning items and taking actions on them.`}
/>
</Group> </Group>
<Button
onClick={toggleFullscreen}
size='sm'
variant='subtle'
title={t`Toggle Fullscreen`}
>
{fullscreen ? <IconArrowsMaximize /> : <IconArrowsMinimize />}
</Button>
</Group> </Group>
<Space h={'md'} /> <Space h={'md'} />
<Grid maw={'100%'}> <Grid maw={'100%'}>
<Grid.Col span={4}> <Grid.Col span={4}>
<Stack> <Paper p='sm' shadow='xs'>
<Stack gap='xs'> <Stack gap='xs'>
<Group justify='space-between'> <StylishText size='lg'>{t`Barcode Input`}</StylishText>
<TitleWithDoc <Divider />
order={3} <BarcodeInput onScan={scanBarcode} />
text={t`Select the input method you want to use to scan items.`}
>
<Trans>Input</Trans>
</TitleWithDoc>
<Select
value={inputValue}
onChange={setInputValue}
data={inputOptions}
searchable
placeholder={t`Select input method`}
nothingFoundMessage={t`Nothing found`}
/>
</Group>
{inp}
</Stack> </Stack>
<Stack gap={0}> </Paper>
<TitleWithDoc <Paper p='sm' shadow='xs'>
order={3} <Stack gap='xs'>
text={t`Depending on the selected parts actions will be shown here. Not all barcode types are supported currently.`} <StylishText size='lg'>{t`Action`}</StylishText>
> <Divider />
<Trans>Action</Trans>
</TitleWithDoc>
{selection.length === 0 ? ( {selection.length === 0 ? (
<Text> <Alert title={t`No Items Selected`} color='blue'>
<Trans>No selection</Trans> <Trans>Scan and select items to perform actions</Trans>
</Text> </Alert>
) : ( ) : (
<> <>
<Text> <Text>
<Trans>{selection.length} items selected</Trans> <Trans>{selection.length} items selected</Trans>
</Text> </Text>
<Text fz='sm' c='dimmed'> {SelectedActions}
<Trans>General Actions</Trans>
</Text>
<Group>
<ActionIcon
color='red'
onClick={btnDeleteHistory}
title={t`Delete`}
variant='default'
>
<IconTrash />
</ActionIcon>
<ActionIcon
onClick={btnRunSelectedBarcode}
disabled={selection.length > 1}
title={t`Lookup part`}
variant='default'
>
<IconSearch />
</ActionIcon>
<ActionIcon
onClick={btnOpenSelectedLink}
disabled={!selectionLinked}
title={t`Open Link`}
variant='default'
>
<IconLink />
</ActionIcon>
</Group>
<SelectedActions />
</> </>
)} )}
</Stack> </Stack>
</Stack> </Paper>
</Grid.Col> </Grid.Col>
<Grid.Col span={8}> <Grid.Col span={8}>
<Group justify='space-between'> <Paper p='sm' shadow='xs'>
<TitleWithDoc <Stack gap='xs'>
order={3} <Group justify='space-between'>
text={t`History is locally kept in this browser.`} <StylishText size='lg'>{t`Scanned Items`}</StylishText>
detail={t`The history is kept in this browser's local storage. So it won't be shared with other users or other devices but is persistent through reloads. You can select items in the history to perform actions on them. To add items, scan/enter them in the Input area.`} </Group>
> <Divider />
<Trans>History</Trans> <BarcodeScanTable
</TitleWithDoc> records={history}
<ActionIcon onItemsSelected={(ids: string[]) => {
color='red' setSelection(ids);
onClick={btnDeleteFullHistory} }}
variant='default' onItemsDeleted={(ids: string[]) => {
title={t`Delete History`} const newHistory = history.filter(
> (item) => !ids.includes(item.id)
<IconTrash /> );
</ActionIcon>
</Group> historyHandlers.setState(newHistory);
<HistoryTable setHistoryStorage(newHistory);
data={history} }}
selection={selection} />
setSelection={setSelection} </Stack>
/> </Paper>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</> </>
); );
} }
function HistoryTable({
data,
selection,
setSelection
}: Readonly<{
data: ScanItem[];
selection: string[];
setSelection: React.Dispatch<React.SetStateAction<string[]>>;
}>) {
const toggleRow = (id: string) =>
setSelection((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
);
const toggleAll = () =>
setSelection((current) =>
current.length === data.length ? [] : data.map((item) => item.id)
);
const rows = useMemo(() => {
return data.map((item) => {
return (
<tr key={item.id}>
<td>
<Checkbox
checked={selection.includes(item.id)}
onChange={() => toggleRow(item.id)}
/>
</td>
<td>
{item.pk && item.model && item.instance ? (
<RenderInstance model={item.model} instance={item.instance} />
) : (
item.ref
)}
</td>
<td>{item.model}</td>
<td>{item.source}</td>
<td>{item.timestamp?.toString()}</td>
</tr>
);
});
}, [data, selection]);
// rendering
if (data.length === 0)
return (
<Text>
<Trans>No history</Trans>
</Text>
);
return (
<ScrollArea>
<Table miw={800} verticalSpacing='sm'>
<thead>
<tr>
<th style={{ width: rem(40) }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === data.length}
indeterminate={
selection.length > 0 && selection.length !== data.length
}
/>
</th>
<th>
<Trans>Item</Trans>
</th>
<th>
<Trans>Type</Trans>
</th>
<th>
<Trans>Source</Trans>
</th>
<th>
<Trans>Scanned at</Trans>
</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
);
}
// region input stuff
enum InputMethod {
Manual = 'manually',
ImageBarcode = 'imageBarcode'
}
export interface ScanInputInterface {
action: (items: ScanItem[]) => void;
}
interface BarcodeInputProps {
action: (decodedText: string) => void;
notScanningPlaceholder?: string;
}
function InputManual({ action }: Readonly<ScanInputInterface>) {
const [value, setValue] = useState<string>('');
function btnAddItem() {
if (value === '') return;
const new_item: ScanItem = {
id: randomId(),
ref: value,
data: { item: value },
timestamp: new Date(),
source: InputMethod.Manual
};
action([new_item]);
setValue('');
}
function btnAddDummyItem() {
const new_item: ScanItem = {
id: randomId(),
ref: 'Test item',
data: {},
timestamp: new Date(),
source: InputMethod.Manual
};
action([new_item]);
}
return (
<>
<Group>
<TextInput
placeholder={t`Enter item serial or data`}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onKeyDown={getHotkeyHandler([['Enter', btnAddItem]])}
/>
<ActionIcon onClick={btnAddItem} w={16} variant='default'>
<IconPlus />
</ActionIcon>
</Group>
{IS_DEV_OR_DEMO && (
<Button onClick={btnAddDummyItem} variant='outline'>
<Trans>Add dummy item</Trans>
</Button>
)}
</>
);
}
/* Input that uses QR code detection from images */
export function InputImageBarcode({
action,
notScanningPlaceholder = t`Start scanning by selecting a camera and pressing the play button.`
}: Readonly<BarcodeInputProps>) {
const [qrCodeScanner, setQrCodeScanner] = useState<Html5Qrcode | null>(null);
const [camId, setCamId] = useLocalStorage<CameraDevice | null>({
key: 'camId',
defaultValue: null
});
const [cameras, setCameras] = useState<any[]>([]);
const [cameraValue, setCameraValue] = useState<string | null>(null);
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
const documentState = useDocumentVisibility();
let lastValue = '';
// Mount QR code once we are loaded
useEffect(() => {
setQrCodeScanner(new Html5Qrcode('reader'));
// load cameras
Html5Qrcode.getCameras().then((devices) => {
if (devices?.length) {
setCameras(devices);
}
});
}, []);
// set camera value from id
useEffect(() => {
if (camId) {
setCameraValue(camId.id);
}
}, [camId]);
// Stop/start when leaving or reentering page
useEffect(() => {
if (scanningEnabled && documentState === 'hidden') {
btnStopScanning();
setWasAutoPaused(true);
} else if (wasAutoPaused && documentState === 'visible') {
btnStartScanning();
setWasAutoPaused(false);
}
}, [documentState]);
// Scanner functions
function onScanSuccess(decodedText: string) {
qrCodeScanner?.pause();
// dedouplication
if (decodedText === lastValue) {
qrCodeScanner?.resume();
return;
}
lastValue = decodedText;
// submit value upstream
action(decodedText);
qrCodeScanner?.resume();
}
function onScanFailure(error: string) {
if (
error !=
'QR code parse error, error = NotFoundException: No MultiFormat Readers were able to detect the code.'
) {
console.warn(`Code scan error = ${error}`);
}
}
// button handlers
function btnSelectCamera() {
Html5Qrcode.getCameras()
.then((devices) => {
if (devices?.length) {
setCamId(devices[0]);
}
})
.catch((err) => {
showNotification({
title: t`Error while getting camera`,
message: err,
color: 'red',
icon: <IconX />
});
});
}
function btnStartScanning() {
if (camId && qrCodeScanner && !scanningEnabled) {
qrCodeScanner
.start(
camId.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
onScanSuccess(decodedText);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
)
.catch((err: string) => {
showNotification({
title: t`Error while scanning`,
message: err,
color: 'red',
icon: <IconX />
});
});
setScanningEnabled(true);
}
}
function btnStopScanning() {
if (qrCodeScanner && scanningEnabled) {
qrCodeScanner.stop().catch((err: string) => {
showNotification({
title: t`Error while stopping`,
message: err,
color: 'red',
icon: <IconX />
});
});
setScanningEnabled(false);
}
}
// on value change
useEffect(() => {
if (cameraValue === null) return;
if (cameraValue === camId?.id) {
return;
}
const cam = cameras.find((cam) => cam.id === cameraValue);
// stop scanning if cam changed while scanning
if (qrCodeScanner && scanningEnabled) {
// stop scanning
qrCodeScanner.stop().then(() => {
// change ID
setCamId(cam);
// start scanning
qrCodeScanner.start(
cam.id,
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
onScanSuccess(decodedText);
},
(errorMessage) => {
onScanFailure(errorMessage);
}
);
});
} else {
setCamId(cam);
}
}, [cameraValue]);
return (
<Stack gap='xs'>
<Group gap='xs' preventGrowOverflow>
<Select
value={cameraValue}
onChange={setCameraValue}
data={cameras.map((device) => {
return { value: device.id, label: device.label };
})}
maw={200}
size='sm'
/>
{scanningEnabled ? (
<ActionIcon
size='input-sm'
onClick={btnStopScanning}
title={t`Stop scanning`}
variant='default'
>
<IconPlayerStopFilled />
</ActionIcon>
) : (
<ActionIcon
size='input-sm'
onClick={btnStartScanning}
title={t`Start scanning`}
disabled={!camId}
variant='default'
>
<IconPlayerPlayFilled />
</ActionIcon>
)}
<Space style={{ flex: 1 }} />
<Badge color={scanningEnabled ? 'green' : 'orange'}>
{scanningEnabled ? t`Scanning` : t`Not scanning`}
</Badge>
</Group>
{scanningEnabled ? (
<Container px={0} id='reader' w={'100%'} mih='300px' />
) : (
<Container px={0} id='reader' w={'100%'} mih='300px'>
{notScanningPlaceholder}
</Container>
)}
{!camId && (
<Button onClick={btnSelectCamera}>
<Trans>Select Camera</Trans>
</Button>
)}
</Stack>
);
}
// endregion

View File

@ -20,7 +20,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
export function CurrencyTable({ export function CurrencyTable({
setInfo setInfo
}: Readonly<{ setInfo: (info: any) => void }>) { }: Readonly<{ setInfo: (info: any) => void }>) {
const table = useTable('currency'); const table = useTable('currency', 'currency');
const columns = useMemo(() => { const columns = useMemo(() => {
return [ return [
{ {
@ -71,7 +71,6 @@ export function CurrencyTable({
tableState={table} tableState={table}
columns={columns} columns={columns}
props={{ props={{
idAccessor: 'currency',
tableActions: tableActions, tableActions: tableActions,
dataFormatter: (data: any) => { dataFormatter: (data: any) => {
setInfo(data); setInfo(data);

View File

@ -11,7 +11,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable'; import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable';
function AllUnitTable() { function AllUnitTable() {
const table = useTable('all-units'); const table = useTable('all-units', 'name');
const columns = useMemo(() => { const columns = useMemo(() => {
return [ return [
{ {
@ -29,7 +29,6 @@ function AllUnitTable() {
tableState={table} tableState={table}
columns={columns} columns={columns}
props={{ props={{
idAccessor: 'name',
enableSearch: false, enableSearch: false,
enablePagination: false, enablePagination: false,
enableColumnSwitching: false, enableColumnSwitching: false,

View File

@ -81,7 +81,6 @@ export type InvenTreeTableProps<T = any> = {
tableFilters?: TableFilter[]; tableFilters?: TableFilter[];
tableActions?: React.ReactNode[]; tableActions?: React.ReactNode[];
rowExpansion?: DataTableRowExpansionProps<T>; rowExpansion?: DataTableRowExpansionProps<T>;
idAccessor?: string;
dataFormatter?: (data: any) => any; dataFormatter?: (data: any) => any;
rowActions?: (record: T) => RowAction[]; rowActions?: (record: T) => RowAction[];
onRowClick?: (record: T, index: number, event: any) => void; onRowClick?: (record: T, index: number, event: any) => void;
@ -111,8 +110,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
defaultSortColumn: '', defaultSortColumn: '',
barcodeActions: [], barcodeActions: [],
tableFilters: [], tableFilters: [],
tableActions: [], tableActions: []
idAccessor: 'pk'
}; };
/** /**
@ -121,11 +119,13 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
export function InvenTreeTable<T extends Record<string, any>>({ export function InvenTreeTable<T extends Record<string, any>>({
url, url,
tableState, tableState,
tableData,
columns, columns,
props props
}: Readonly<{ }: Readonly<{
url: string; url?: string;
tableState: TableState; tableState: TableState;
tableData?: any[];
columns: TableColumn<T>[]; columns: TableColumn<T>[];
props: InvenTreeTableProps<T>; props: InvenTreeTableProps<T>;
}>) { }>) {
@ -158,12 +158,16 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Request OPTIONS data from the API, before we load the table // Request OPTIONS data from the API, before we load the table
const tableOptionQuery = useQuery({ const tableOptionQuery = useQuery({
enabled: true, enabled: !!url && !tableData,
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching], queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
retry: 3, retry: 3,
refetchOnMount: true, refetchOnMount: true,
gcTime: 5000, gcTime: 5000,
queryFn: async () => { queryFn: async () => {
if (!url) {
return null;
}
if (props.enableColumnCaching == false) { if (props.enableColumnCaching == false) {
return null; return null;
} }
@ -445,6 +449,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
const fetchTableData = async () => { const fetchTableData = async () => {
const queryParams = getTableFilters(true); const queryParams = getTableFilters(true);
if (!url) {
return [];
}
return api return api
.get(url, { .get(url, {
params: queryParams, params: queryParams,
@ -499,7 +507,12 @@ export function InvenTreeTable<T extends Record<string, any>>({
}); });
}; };
const { data, isFetching, isLoading, refetch } = useQuery({ const {
data: apiData,
isFetching,
isLoading,
refetch
} = useQuery({
queryKey: [ queryKey: [
'tabledata', 'tabledata',
url, url,
@ -511,6 +524,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
tableState.activeFilters, tableState.activeFilters,
tableState.searchTerm tableState.searchTerm
], ],
enabled: !!url && !tableData,
queryFn: fetchTableData, queryFn: fetchTableData,
refetchOnMount: true refetchOnMount: true
}); });
@ -531,13 +545,15 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Update tableState.records when new data received // Update tableState.records when new data received
useEffect(() => { useEffect(() => {
tableState.setRecords(data ?? []); const data = tableData ?? apiData ?? [];
tableState.setRecords(data);
// set pagesize to length if pagination is disabled // set pagesize to length if pagination is disabled
if (!tableProps.enablePagination) { if (!tableProps.enablePagination) {
tableState.setPageSize(data?.length ?? defaultPageSize); tableState.setPageSize(data?.length ?? defaultPageSize);
} }
}, [data]); }, [tableData, apiData]);
// Callback when a cell is clicked // Callback when a cell is clicked
const handleCellClick = useCallback( const handleCellClick = useCallback(
@ -645,13 +661,29 @@ export function InvenTreeTable<T extends Record<string, any>>({
]); ]);
const optionalParams = useMemo(() => { const optionalParams = useMemo(() => {
const optionalParamsa: Record<string, any> = {}; let _params: Record<string, any> = {};
if (tableProps.enablePagination) { if (tableProps.enablePagination) {
optionalParamsa['recordsPerPageOptions'] = PAGE_SIZES; _params = {
optionalParamsa['onRecordsPerPageChange'] = updatePageSize; ..._params,
totalRecords: tableState.recordCount,
recordsPerPage: tableState.pageSize,
page: tableState.page,
onPageChange: tableState.setPage,
recordsPerPageOptions: PAGE_SIZES,
onRecordsPerPageChange: updatePageSize
};
} }
return optionalParamsa;
}, [tableProps.enablePagination]); return _params;
}, [
tableProps.enablePagination,
tableState.recordCount,
tableState.pageSize,
tableState.page,
tableState.setPage,
updatePageSize
]);
return ( return (
<> <>
@ -678,12 +710,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
highlightOnHover highlightOnHover
loaderType={loader} loaderType={loader}
pinLastColumn={tableProps.rowActions != undefined} pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableProps.idAccessor} idAccessor={tableState.idAccessor ?? 'pk'}
minHeight={tableProps.minHeight ?? 300} minHeight={tableProps.minHeight ?? 300}
totalRecords={tableState.recordCount}
recordsPerPage={tableState.pageSize}
page={tableState.page}
onPageChange={tableState.setPage}
sortStatus={sortStatus} sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange} onSortStatusChange={handleSortStatusChange}
selectedRecords={ selectedRecords={

View File

@ -42,7 +42,7 @@ export default function InvenTreeTableHeader({
filters, filters,
toggleColumn toggleColumn
}: Readonly<{ }: Readonly<{
tableUrl: string; tableUrl?: string;
tableState: TableState; tableState: TableState;
tableProps: InvenTreeTableProps<any>; tableProps: InvenTreeTableProps<any>;
hasSwitchableColumns: boolean; hasSwitchableColumns: boolean;
@ -92,7 +92,7 @@ export default function InvenTreeTableHeader({
}; };
const deleteRecords = useDeleteApiFormModal({ const deleteRecords = useDeleteApiFormModal({
url: tableUrl, url: tableUrl ?? '',
title: t`Delete Selected Items`, title: t`Delete Selected Items`,
preFormContent: ( preFormContent: (
<Alert <Alert

View File

@ -0,0 +1,124 @@
import { t } from '@lingui/macro';
import { IconTrash } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { BarcodeScanItem } from '../../components/barcodes/BarcodeScanItem';
import { ActionButton } from '../../components/buttons/ActionButton';
import { RenderInstance } from '../../components/render/Instance';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowViewAction } from '../RowActions';
/**
* A table for showing barcode scan history data on the scan index page
*/
export default function BarcodeScanTable({
records,
onItemsSelected,
onItemsDeleted
}: {
records: BarcodeScanItem[];
onItemsSelected: (items: string[]) => void;
onItemsDeleted: (items: string[]) => void;
}) {
const navigate = useNavigate();
const user = useUserState();
const table = useTable('barcode-scan-results', 'id');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'instance',
title: t`Item`,
sortable: false,
switchable: false,
render: (record) => {
if (record.instance) {
return (
<RenderInstance model={record.model} instance={record.instance} />
);
} else {
return '-';
}
}
},
{
accessor: 'model',
title: t`Model`,
sortable: false
},
{
accessor: 'barcode',
title: t`Barcode`,
sortable: false
},
{
accessor: 'timestamp',
title: t`Timestamp`,
sortable: false,
render: (record) => {
return record.timestamp?.toLocaleString();
}
}
];
}, []);
const rowActions = useCallback((record: BarcodeScanItem) => {
const actions: RowAction[] = [];
if (record.model && record.pk && record.instance) {
actions.push(
RowViewAction({
title: t`View Item`,
modelId: record.instance?.pk,
modelType: record.model,
navigate: navigate,
hidden: !user.hasViewPermission(record.model)
})
);
}
return actions;
}, []);
const tableActions = useMemo(() => {
return [
<ActionButton
disabled={!table.hasSelectedRecords}
icon={<IconTrash />}
color='red'
tooltip={t`Delete selected records`}
onClick={() => {
onItemsDeleted(table.selectedIds);
table.clearSelectedRecords();
}}
/>
];
}, [table.hasSelectedRecords, table.selectedIds]);
useEffect(() => {
onItemsSelected(table.selectedIds);
}, [table.selectedIds]);
return (
<>
<InvenTreeTable
tableState={table}
tableData={records}
columns={tableColumns}
props={{
enableFilters: false,
enableSelection: true,
enablePagination: false,
enableSearch: false,
enableRefresh: false,
rowActions: rowActions,
tableActions: tableActions
}}
/>
</>
);
}

View File

@ -19,7 +19,7 @@ export interface PluginRegistryErrorI {
* Table displaying list of plugin registry errors * Table displaying list of plugin registry errors
*/ */
export default function PluginErrorTable() { export default function PluginErrorTable() {
const table = useTable('registryErrors'); const table = useTable('registryErrors', 'id');
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] = const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
useMemo( useMemo(
@ -49,7 +49,6 @@ export default function PluginErrorTable() {
props={{ props={{
dataFormatter: (data: any) => dataFormatter: (data: any) =>
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })), data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
idAccessor: 'id',
enableDownload: false, enableDownload: false,
enableFilters: false, enableFilters: false,
enableSearch: false enableSearch: false

View File

@ -446,6 +446,7 @@ export default function StockItemTestResultTable({
return ( return (
<DataTable <DataTable
key={record.pk} key={record.pk}
idAccessor={'test'}
noHeader noHeader
columns={cols} columns={cols}
records={results.slice(0, -1)} records={results.slice(0, -1)}

View File

@ -74,6 +74,7 @@ export const test = baseTest.extend({
url != 'http://localhost:8000/api/barcode/' && url != 'http://localhost:8000/api/barcode/' &&
url != 'https://docs.inventree.org/en/versions.json' && url != 'https://docs.inventree.org/en/versions.json' &&
url != 'http://localhost:5173/favicon.ico' && url != 'http://localhost:5173/favicon.ico' &&
!url.startsWith('https://api.github.com/repos/inventree') &&
!url.startsWith('http://localhost:8000/api/news/') && !url.startsWith('http://localhost:8000/api/news/') &&
!url.startsWith('http://localhost:8000/api/notifications/') && !url.startsWith('http://localhost:8000/api/notifications/') &&
!url.startsWith('chrome://') && !url.startsWith('chrome://') &&

View File

@ -1,7 +1,7 @@
import { test } from './baseFixtures.js'; import { test } from './baseFixtures.js';
import { doQuickLogin } from './login.js'; import { doQuickLogin } from './login.js';
test('Modals as admin', async ({ page }) => { test('Modals - Admin', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree'); await doQuickLogin(page, 'admin', 'inventree');
// use server info // use server info
@ -49,15 +49,4 @@ test('Modals as admin', async ({ page }) => {
.getByRole('button', { name: 'About InvenTree About the InvenTree org' }) .getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click(); .click();
await page.getByRole('cell', { name: 'InvenTree Version' }).click(); await page.getByRole('cell', { name: 'InvenTree Version' }).click();
await page.goto('./platform/');
// Barcode scanning window
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByRole('banner').getByRole('button').click();
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByRole('button', { name: 'Close modal' }).click();
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.waitForTimeout(500);
await page.getByRole('banner').getByRole('button').click();
}); });

View File

@ -268,6 +268,4 @@ test('Build Order - Filters', async ({ page }) => {
await openFilterDrawer(page); await openFilterDrawer(page);
await clickButtonIfVisible(page, 'Clear Filters'); await clickButtonIfVisible(page, 'Clear Filters');
await page.waitForTimeout(2500);
}); });

View File

@ -3,6 +3,70 @@ import { baseUrl } from '../defaults.ts';
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts'; import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
import { doQuickLogin } from '../login.ts'; import { doQuickLogin } from '../login.ts';
test('Purchase Orders', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
// Check for expected values
await page.getByRole('cell', { name: 'PO0014' }).waitFor();
await page.getByText('Wire-E-Coyote').waitFor();
await page.getByText('Cancelled').first().waitFor();
await page.getByText('Pending').first().waitFor();
await page.getByText('On Hold').first().waitFor();
// Click through to a particular purchase order
await page.getByRole('cell', { name: 'PO0013' }).click();
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Purchase Orders - Barcodes', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/purchasing/purchase-order/13/detail`);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
// Display QR code
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-view').click();
await page.getByRole('img', { name: 'QR Code' }).waitFor();
await page.getByRole('banner').getByRole('button').click();
// Link to barcode
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
await page.getByLabel('barcode-input-scanner').click();
// Simulate barcode scan
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
// Ensure we can scan back to this page, with the associated barcode
await page.goto(`${baseUrl}/home`);
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByPlaceholder('Enter barcode data').fill('1234567890');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Purchase Order: PO0013', { exact: true }).waitFor();
// Unlink barcode
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
await page.getByText('This will remove the link to').waitFor();
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Purchase Orders - General', async ({ page }) => { test('Purchase Orders - General', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);

View File

@ -3,6 +3,57 @@ import { baseUrl } from '../defaults.ts';
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts'; import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
import { doQuickLogin } from '../login.ts'; import { doQuickLogin } from '../login.ts';
test('Sales Orders - Tabs', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/sales/index/`);
await page.waitForURL('**/platform/sales/**');
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.waitForURL('**/platform/sales/index/salesorders');
await page.getByRole('tab', { name: 'Return Orders' }).click();
// Customers
await page.getByRole('tab', { name: 'Customers' }).click();
await page.getByText('Customer A').click();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('tab', { name: 'Assigned Stock' }).click();
await page.getByRole('tab', { name: 'Return Orders' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('cell', { name: 'Dorathy Gross' }).waitFor();
await page
.getByRole('row', { name: 'Dorathy Gross dorathy.gross@customer.com' })
.waitFor();
// Sales Order Details
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.getByRole('cell', { name: 'SO0001' }).click();
await page
.getByLabel('Order Details')
.getByText('Selling some stuff')
.waitFor();
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Shipments' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
await page.getByText('No records found').first().waitFor();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByText('No attachments found').first().waitFor();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Order Details' }).click();
// Return Order Details
await page.getByRole('link', { name: 'Customer A' }).click();
await page.getByRole('tab', { name: 'Return Orders' }).click();
await page.getByRole('cell', { name: 'RMA-' }).click();
await page.getByText('RMA-0001', { exact: true }).waitFor();
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
});
test('Sales Orders - Basic Tests', async ({ page }) => { test('Sales Orders - Basic Tests', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
@ -129,55 +180,3 @@ test('Sales Orders - Shipments', async ({ page }) => {
await page.getByText('Quantity: 42').click(); await page.getByText('Quantity: 42').click();
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
}); });
test('Purchase Orders', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
// Check for expected values
await page.getByRole('cell', { name: 'PO0014' }).waitFor();
await page.getByText('Wire-E-Coyote').waitFor();
await page.getByText('Cancelled').first().waitFor();
await page.getByText('Pending').first().waitFor();
await page.getByText('On Hold').first().waitFor();
// Click through to a particular purchase order
await page.getByRole('cell', { name: 'PO0013' }).click();
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Purchase Orders - Barcodes', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/purchasing/purchase-order/13/detail`);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
// Display QR code
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-view').click();
await page.getByRole('img', { name: 'QR Code' }).waitFor();
await page.getByRole('banner').getByRole('button').click();
// Link to barcode
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
await page.getByRole('heading', { name: 'Link Barcode' }).waitFor();
await page
.getByPlaceholder('Scan barcode data here using')
.fill('1234567890');
await page.getByRole('button', { name: 'Link' }).click();
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
// Unlink barcode
await page.getByLabel('action-menu-barcode-actions').click();
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
await page.getByText('This will remove the link to').waitFor();
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});

View File

@ -2,157 +2,120 @@ import { test } from '../baseFixtures';
import { baseUrl } from '../defaults'; import { baseUrl } from '../defaults';
import { doQuickLogin } from '../login'; import { doQuickLogin } from '../login';
async function defaultScanTest(page, search_text) { const scan = async (page, barcode) => {
await page.getByLabel('barcode-input-scanner').click();
await page.getByLabel('barcode-scan-keyboard-input').fill(barcode);
await page.getByRole('button', { name: 'Scan', exact: true }).click();
};
test('Scanning - Dialog', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan`); await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByPlaceholder('Select input method').click(); await scan(page, '{"part": 15}');
await page.getByRole('option', { name: 'Manual input' }).click();
await page.getByPlaceholder('Enter item serial or data').click();
// nonsense data await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
await page.getByPlaceholder('Enter item serial or data').fill('123'); await page.getByText('Available:').waitFor();
await page.getByPlaceholder('Enter item serial or data').press('Enter'); await page.getByText('Required:').waitFor();
await page.getByRole('cell', { name: '123' }).click(); });
await page.getByRole('cell', { name: 'manually' }).click();
await page.getByRole('button', { name: 'Lookup part' }).click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await page.getByPlaceholder('Enter item serial or data').fill(search_text); test('Scanning - Basic', async ({ page }) => {
await page.getByPlaceholder('Enter item serial or data').press('Enter');
await page.getByRole('checkbox').nth(2).check();
await page.getByRole('button', { name: 'Lookup part' }).click();
}
test('Scanning', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.getByLabel('navigation-menu').click(); // Navigate to the 'scan' page
await page.getByRole('button', { name: 'System Information' }).click();
await page.locator('button').filter({ hasText: 'Close' }).click();
await page.getByLabel('navigation-menu').click(); await page.getByLabel('navigation-menu').click();
await page.getByRole('button', { name: 'Scan Barcode' }).click(); await page.getByRole('button', { name: 'Scan Barcode' }).click();
await page.getByPlaceholder('Select input method').click(); await page.getByText('Scan or enter barcode data').waitFor();
await page.getByRole('option', { name: 'Manual input' }).click();
await page.getByPlaceholder('Enter item serial or data').click(); // Select the scanner input
await page.getByPlaceholder('Enter item serial or data').fill('123'); await page.getByLabel('barcode-input-scanner').click();
await page.getByPlaceholder('Enter item serial or data').press('Enter'); await page.getByPlaceholder('Enter barcode data').fill('123-abc');
await page.getByRole('cell', { name: 'manually' }).click(); await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByRole('button', { name: 'Lookup part' }).click();
await page.getByPlaceholder('Select input method').click(); // Select the camera input
await page.getByRole('option', { name: 'Manual input' }).click(); await page.getByLabel('barcode-input-camera').click();
await page.getByText('Start scanning by selecting a camera').waitFor();
await page.getByText('No match found for barcode').waitFor();
}); });
test('Scanning (Part)', async ({ page }) => { test('Scanning - Part', async ({ page }) => {
await defaultScanTest(page, '{"part": 1}'); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan/`);
await scan(page, '{"part": 1}');
// part: 1
await page.getByText('R_10R_0402_1%').waitFor(); await page.getByText('R_10R_0402_1%').waitFor();
await page.getByText('Stock:').waitFor(); await page.getByText('Stock:').waitFor();
await page.getByRole('cell', { name: 'part' }).waitFor(); await page.getByRole('cell', { name: 'part', exact: true }).waitFor();
}); });
test('Scanning (Stockitem)', async ({ page }) => { test('Scanning - Stockitem', async ({ page }) => {
// TODO: Come back to here and re-enable this test await doQuickLogin(page);
// TODO: Something is wrong with the test, it's not working as expected await page.goto(`${baseUrl}/scan/`);
// TODO: The barcode scanning page needs some attention in general await scan(page, '{"stockitem": 408}');
/*
* TODO: 2024-11-08 : https://github.com/inventree/InvenTree/pull/8445
await defaultScanTest(page, '{"stockitem": 408}');
// stockitem: 408
await page.getByText('1551ABK').waitFor(); await page.getByText('1551ABK').waitFor();
await page.getByText('Quantity: 100').waitFor(); await page.getByText('Quantity: 100').waitFor();
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor(); await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
*/
}); });
test('Scanning (StockLocation)', async ({ page }) => { test('Scanning - StockLocation', async ({ page }) => {
await defaultScanTest(page, '{"stocklocation": 3}'); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan/`);
await scan(page, '{"stocklocation": 3}');
// stocklocation: 3 // stocklocation: 3
await page.getByText('Factory/Storage Room B', { exact: true }).waitFor(); await page.getByText('Factory/Storage Room B', { exact: true }).waitFor();
await page.getByText('Storage Room B (green door)').waitFor(); await page.getByText('Storage Room B (green door)').waitFor();
await page.getByRole('cell', { name: 'stocklocation' }).waitFor(); await page
.getByRole('cell', { name: 'stocklocation', exact: true })
.waitFor();
}); });
test('Scanning (SupplierPart)', async ({ page }) => { test('Scanning - SupplierPart', async ({ page }) => {
await defaultScanTest(page, '{"supplierpart": 204}'); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan/`);
await scan(page, '{"supplierpart": 204}');
// supplierpart: 204 // supplierpart: 204
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await page.getByText('1551ABK').first().waitFor(); await page.getByText('1551ABK').first().waitFor();
await page.getByRole('cell', { name: 'supplierpart' }).waitFor(); await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor();
}); });
test('Scanning (PurchaseOrder)', async ({ page }) => { test('Scanning - PurchaseOrder', async ({ page }) => {
await defaultScanTest(page, '{"purchaseorder": 12}'); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan/`);
await scan(page, '{"purchaseorder": 12}');
// purchaseorder: 12 // purchaseorder: 12
await page.getByText('PO0012').waitFor(); await page.getByText('PO0012').waitFor();
await page.getByText('Wire from Wirey').waitFor(); await page.getByText('Wire from Wirey').waitFor();
await page.getByRole('cell', { name: 'purchaseorder' }).waitFor(); await page
.getByRole('cell', { name: 'purchaseorder', exact: true })
.waitFor();
}); });
test('Scanning (SalesOrder)', async ({ page }) => { test('Scanning - SalesOrder', async ({ page }) => {
await defaultScanTest(page, '{"salesorder": 6}'); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan/`);
await scan(page, '{"salesorder": 6}');
// salesorder: 6 // salesorder: 6
await page.getByText('SO0006').waitFor(); await page.getByText('SO0006').waitFor();
await page.getByText('Selling more stuff to this').waitFor(); await page.getByText('Selling more stuff to this').waitFor();
await page.getByRole('cell', { name: 'salesorder' }).waitFor(); await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor();
}); });
test('Scanning (Build)', async ({ page }) => { test('Scanning - Build', async ({ page }) => {
await defaultScanTest(page, '{"build": 8}'); await doQuickLogin(page);
await page.goto(`${baseUrl}/scan/`);
await scan(page, '{"build": 8}');
// build: 8 // build: 8
await page.getByText('BO0008').waitFor(); await page.getByText('BO0008').waitFor();
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('Scanning (General)', async ({ page }) => {
await defaultScanTest(page, '{"unknown": 312}');
await page.getByText('"unknown": 312').waitFor();
// checkAll
await page.getByRole('checkbox').nth(0).check();
// Delete
await page.getByRole('button', { name: 'Delete', exact: true }).click();
// Reload to check history is working
await page.goto(`${baseUrl}/scan`);
await page.getByText('"unknown": 312').waitFor();
// Clear history
await page.getByRole('button', { name: 'Delete History' }).click();
await page.getByText('No history').waitFor();
// reload again
await page.goto(`${baseUrl}/scan`);
await page.getByText('No history').waitFor();
// Empty dummy input
await page.getByPlaceholder('Enter item serial or data').fill('');
await page.getByPlaceholder('Enter item serial or data').press('Enter');
// Empty add dummy item
await page.getByRole('button', { name: 'Add dummy item' }).click();
// Empty plus sign
await page
.locator('div')
.filter({ hasText: /^InputAdd dummy item$/ })
.getByRole('button')
.first()
.click();
// Toggle fullscreen
await page.getByRole('button', { name: 'Toggle Fullscreen' }).click();
await page.waitForTimeout(1000);
await page.getByRole('button', { name: 'Toggle Fullscreen' }).click();
});

View File

@ -206,8 +206,6 @@ test('Stock - Stock Actions', async ({ page }) => {
await page.getByText('Unavailable').waitFor(); await page.getByText('Unavailable').waitFor();
await page.getByLabel('action-menu-stock-operations').click(); await page.getByLabel('action-menu-stock-operations').click();
await page.getByLabel('action-menu-stock-operations-return').click(); await page.getByLabel('action-menu-stock-operations-return').click();
await page.waitForTimeout(2500);
}); });
test('Stock - Tracking', async ({ page }) => { test('Stock - Tracking', async ({ page }) => {

View File

@ -2,57 +2,6 @@ import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js'; import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js'; import { doQuickLogin } from './login.js';
test('Sales', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/sales/index/`);
await page.waitForURL('**/platform/sales/**');
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.waitForURL('**/platform/sales/index/salesorders');
await page.getByRole('tab', { name: 'Return Orders' }).click();
// Customers
await page.getByRole('tab', { name: 'Customers' }).click();
await page.getByText('Customer A').click();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('tab', { name: 'Assigned Stock' }).click();
await page.getByRole('tab', { name: 'Return Orders' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.getByRole('tab', { name: 'Contacts' }).click();
await page.getByRole('cell', { name: 'Dorathy Gross' }).waitFor();
await page
.getByRole('row', { name: 'Dorathy Gross dorathy.gross@customer.com' })
.waitFor();
// Sales Order Details
await page.getByRole('tab', { name: 'Sales Orders' }).click();
await page.getByRole('cell', { name: 'SO0001' }).click();
await page
.getByLabel('Order Details')
.getByText('Selling some stuff')
.waitFor();
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Shipments' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
await page.getByText('No records found').first().waitFor();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByText('No attachments found').first().waitFor();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Order Details' }).click();
// Return Order Details
await page.getByRole('link', { name: 'Customer A' }).click();
await page.getByRole('tab', { name: 'Return Orders' }).click();
await page.getByRole('cell', { name: 'RMA-' }).click();
await page.getByText('RMA-0001', { exact: true }).waitFor();
await page.getByRole('tab', { name: 'Line Items' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
});
test('Company', async ({ page }) => { test('Company', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);

View File

@ -92,8 +92,6 @@ test('Login - Failures', async ({ page }) => {
await page.getByLabel('login-password').fill(''); await page.getByLabel('login-password').fill('');
await loginWithError(); await loginWithError();
await page.waitForTimeout(2500);
}); });
test('Login - Change Password', async ({ page }) => { test('Login - Change Password', async ({ page }) => {

View File

@ -122,7 +122,4 @@ test('Plugins - Locate Item', async ({ page, request }) => {
await page.getByLabel('action-button-locate-item').click(); await page.getByLabel('action-button-locate-item').click();
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item location requested').waitFor(); await page.getByText('Item location requested').waitFor();
await page.waitForTimeout(2500);
return;
}); });