2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-14 08:19:54 +00:00

[UI] Barcode form inputs (#10973)

* Add barcode buttons to related fields

- Only field types which support barcodes

* Add per-user settings for barcode support

* Fill form field with scanned data

* Updated docs

* Fix duplicate setting

* Add playwright tests

* Fix duplicate setting in docs

* Fix broken link

* Fix memo deps

* Fix typo

* Remove setting

* Updated playwright tests

* Improved typing
This commit is contained in:
Oliver
2025-12-07 18:31:32 +11:00
committed by GitHub
parent f4186e73ff
commit ae70c22485
16 changed files with 309 additions and 108 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -69,6 +69,19 @@ To access this page, select *Scan Barcode* from the main navigation menu:
{{ image("barcode/barcode_nav_menu.png", "Barcode menu item") }} {{ image("barcode/barcode_nav_menu.png", "Barcode menu item") }}
{{ image("barcode/barcode_scan_page.png", "Barcode scan page") }} {{ image("barcode/barcode_scan_page.png", "Barcode scan page") }}
### Barcodes in Forms
The InvenTree user interface supports direct scanning of barcodes within certain forms in the web UI. This means that any form field which points to a model which supports barcodes can accept barcode input. If barcode scanning is supported for a particular field, a barcode icon will be displayed next to the input field:
{{ image("barcode/barcode_field.png", "Barcode form field") }}
To scan a barcode into a form field, click this barcode icon. A barcode scanning dialog will be displayed, allowing the user to scan a barcode using their preferred input method:
{{ image("barcode/barcode_field_dialog.png", "Barcode field scan dialog") }}
Once scanned, the form field will be automatically populated with the correct item.
{{ image("barcode/barcode_field_filled.png", "Barcode field populated") }}
## App Integration ## App Integration
@@ -81,3 +94,10 @@ If enabled, InvenTree can retain logs of the most recent barcode scans. This can
Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging. Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging.
The barcode history can be viewed via the admin panel in the web interface. The barcode history can be viewed via the admin panel in the web interface.
## Barcode Settings
There are a number of settings which control the behavior of barcodes within InvenTree. For more information, refer to the links below:
- [Global Barcode Settings](../settings/global.md#barcodes)
- [User Preferences for Barcode Scanning](../settings/user.md#display-settings)

View File

@@ -78,7 +78,7 @@ If this setting is enabled, users can reset their password via email. This requi
If this setting is enabled, users must have multi-factor authentication enabled to log in. If this setting is enabled, users must have multi-factor authentication enabled to log in.
#### Auto Fil SSO Users #### Auto Fill SSO Users
Automatically fill out user-details from SSO account-data. If this feature is enabled the user is only asked for their username, first- and surname if those values can not be gathered from their SSO profile. This might lead to unwanted usernames bleeding over. Automatically fill out user-details from SSO account-data. If this feature is enabled the user is only asked for their username, first- and surname if those values can not be gathered from their SSO profile. This might lead to unwanted usernames bleeding over.

View File

@@ -22,6 +22,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("STICKY_HEADER") }} {{ usersetting("STICKY_HEADER") }}
{{ usersetting("STICKY_TABLE_HEADER") }} {{ usersetting("STICKY_TABLE_HEADER") }}
{{ usersetting("SHOW_SPOTLIGHT") }} {{ usersetting("SHOW_SPOTLIGHT") }}
{{ usersetting("BARCODE_IN_FORM_FIELDS") }}
{{ usersetting("DATE_DISPLAY_FORMAT") }} {{ usersetting("DATE_DISPLAY_FORMAT") }}
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }} {{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }} {{ usersetting("DISPLAY_STOCKTAKE_TAB") }}

View File

@@ -41,6 +41,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'BARCODE_IN_FORM_FIELDS': {
'name': _('Barcode Scanner in Form Fields'),
'description': _('Allow barcode scanner input in form fields'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_PARTS': { 'SEARCH_PREVIEW_SHOW_PARTS': {
'name': _('Search Parts'), 'name': _('Search Parts'),
'description': _('Display parts in search preview window'), 'description': _('Display parts in search preview window'),

View File

@@ -10,6 +10,7 @@ export interface ModelInformationInterface {
url_detail?: string; url_detail?: string;
api_endpoint: ApiEndpoints; api_endpoint: ApiEndpoints;
admin_url?: string; admin_url?: string;
supports_barcode?: boolean;
icon: keyof InvenTreeIconType; icon: keyof InvenTreeIconType;
} }
@@ -31,6 +32,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/part/:pk/', url_detail: '/part/:pk/',
api_endpoint: ApiEndpoints.part_list, api_endpoint: ApiEndpoints.part_list,
admin_url: '/part/part/', admin_url: '/part/part/',
supports_barcode: true,
icon: 'part' icon: 'part'
}, },
parameter: { parameter: {
@@ -60,6 +62,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/supplier-part/:pk/', url_detail: '/purchasing/supplier-part/:pk/',
api_endpoint: ApiEndpoints.supplier_part_list, api_endpoint: ApiEndpoints.supplier_part_list,
admin_url: '/company/supplierpart/', admin_url: '/company/supplierpart/',
supports_barcode: true,
icon: 'supplier_part' icon: 'supplier_part'
}, },
manufacturerpart: { manufacturerpart: {
@@ -69,6 +72,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/manufacturer-part/:pk/', url_detail: '/purchasing/manufacturer-part/:pk/',
api_endpoint: ApiEndpoints.manufacturer_part_list, api_endpoint: ApiEndpoints.manufacturer_part_list,
admin_url: '/company/manufacturerpart/', admin_url: '/company/manufacturerpart/',
supports_barcode: true,
icon: 'manufacturers' icon: 'manufacturers'
}, },
partcategory: { partcategory: {
@@ -87,6 +91,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/stock/item/:pk/', url_detail: '/stock/item/:pk/',
api_endpoint: ApiEndpoints.stock_item_list, api_endpoint: ApiEndpoints.stock_item_list,
admin_url: '/stock/stockitem/', admin_url: '/stock/stockitem/',
supports_barcode: true,
icon: 'stock' icon: 'stock'
}, },
stocklocation: { stocklocation: {
@@ -96,6 +101,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/stock/location/:pk/', url_detail: '/stock/location/:pk/',
api_endpoint: ApiEndpoints.stock_location_list, api_endpoint: ApiEndpoints.stock_location_list,
admin_url: '/stock/stocklocation/', admin_url: '/stock/stocklocation/',
supports_barcode: true,
icon: 'location' icon: 'location'
}, },
stocklocationtype: { stocklocationtype: {
@@ -117,6 +123,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/manufacturing/build-order/:pk/', url_detail: '/manufacturing/build-order/:pk/',
api_endpoint: ApiEndpoints.build_order_list, api_endpoint: ApiEndpoints.build_order_list,
admin_url: '/build/build/', admin_url: '/build/build/',
supports_barcode: true,
icon: 'build_order' icon: 'build_order'
}, },
buildline: { buildline: {
@@ -155,6 +162,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/purchase-order/:pk/', url_detail: '/purchasing/purchase-order/:pk/',
api_endpoint: ApiEndpoints.purchase_order_list, api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/', admin_url: '/order/purchaseorder/',
supports_barcode: true,
icon: 'purchase_orders' icon: 'purchase_orders'
}, },
purchaseorderlineitem: { purchaseorderlineitem: {
@@ -170,6 +178,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/sales/sales-order/:pk/', url_detail: '/sales/sales-order/:pk/',
api_endpoint: ApiEndpoints.sales_order_list, api_endpoint: ApiEndpoints.sales_order_list,
admin_url: '/order/salesorder/', admin_url: '/order/salesorder/',
supports_barcode: true,
icon: 'sales_orders' icon: 'sales_orders'
}, },
salesordershipment: { salesordershipment: {
@@ -178,6 +187,7 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/sales/index/shipments', url_overview: '/sales/index/shipments',
url_detail: '/sales/shipment/:pk/', url_detail: '/sales/shipment/:pk/',
api_endpoint: ApiEndpoints.sales_order_shipment_list, api_endpoint: ApiEndpoints.sales_order_shipment_list,
supports_barcode: true,
icon: 'shipment' icon: 'shipment'
}, },
returnorder: { returnorder: {
@@ -187,6 +197,7 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/sales/return-order/:pk/', url_detail: '/sales/return-order/:pk/',
api_endpoint: ApiEndpoints.return_order_list, api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/', admin_url: '/order/returnorder/',
supports_barcode: true,
icon: 'return_orders' icon: 'return_orders'
}, },
returnorderlineitem: { returnorderlineitem: {

View File

@@ -19,6 +19,11 @@ export type BarcodeScanResult = {
error?: string; error?: string;
}; };
export type BarcodeScanSuccessCallback = (
barcode: string,
response: any
) => void;
// Callback function for handling a barcode scan // Callback function for handling a barcode scan
// This function should return true if the barcode was handled successfully // This function should return true if the barcode was handled successfully
export type BarcodeScanCallback = ( export type BarcodeScanCallback = (
@@ -31,13 +36,15 @@ export default function BarcodeScanDialog({
opened, opened,
callback, callback,
modelType, modelType,
onClose onClose,
onScanSuccess
}: Readonly<{ }: Readonly<{
title?: string; title?: string;
opened: boolean; opened: boolean;
modelType?: ModelType; modelType?: ModelType;
callback?: BarcodeScanCallback; callback?: BarcodeScanCallback;
onClose: () => void; onClose: () => void;
onScanSuccess?: BarcodeScanSuccessCallback;
}>) { }>) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -53,6 +60,7 @@ export default function BarcodeScanDialog({
<ScanInputHandler <ScanInputHandler
navigate={navigate} navigate={navigate}
onClose={onClose} onClose={onClose}
onScanSuccess={onScanSuccess}
modelType={modelType} modelType={modelType}
callback={callback} callback={callback}
/> />
@@ -65,10 +73,12 @@ export function ScanInputHandler({
callback, callback,
modelType, modelType,
onClose, onClose,
onScanSuccess,
navigate navigate
}: Readonly<{ }: Readonly<{
callback?: BarcodeScanCallback; callback?: BarcodeScanCallback;
onClose: () => void; onClose: () => void;
onScanSuccess?: BarcodeScanSuccessCallback;
modelType?: ModelType; modelType?: ModelType;
navigate: NavigateFunction; navigate: NavigateFunction;
}>) { }>) {
@@ -94,7 +104,13 @@ export function ScanInputHandler({
data[model_type]['pk'] data[model_type]['pk']
); );
onClose(); onClose();
navigate(url);
if (onScanSuccess) {
onScanSuccess(data['barcode'], data);
} else {
navigate(url);
}
match = true; match = true;
break; break;
} }

View File

@@ -1,19 +1,32 @@
import type { ModelType } from '@lib/index';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core'; import { ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { IconQrcode } from '@tabler/icons-react'; import { IconQrcode } from '@tabler/icons-react';
import BarcodeScanDialog from '../barcodes/BarcodeScanDialog'; import BarcodeScanDialog, {
type BarcodeScanCallback,
type BarcodeScanSuccessCallback
} from '../barcodes/BarcodeScanDialog';
/** /**
* A button which opens the QR code scanner modal * A button which opens the QR code scanner modal
*/ */
export function ScanButton() { export function ScanButton({
modelType,
callback,
onScanSuccess
}: {
modelType?: ModelType;
callback?: BarcodeScanCallback;
onScanSuccess?: BarcodeScanSuccessCallback;
}) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
return ( return (
<> <>
<Tooltip position='bottom-end' label={t`Scan Barcode`}> <Tooltip position='bottom-end' label={t`Scan Barcode`}>
<ActionIcon <ActionIcon
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
onClick={open} onClick={open}
variant='transparent' variant='transparent'
title={t`Open Barcode Scanner`} title={t`Open Barcode Scanner`}
@@ -21,7 +34,13 @@ export function ScanButton() {
<IconQrcode /> <IconQrcode />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<BarcodeScanDialog opened={opened} onClose={close} /> <BarcodeScanDialog
opened={opened}
modelType={modelType}
callback={callback}
onClose={close}
onScanSuccess={onScanSuccess}
/>
</> </>
); );
} }

View File

@@ -1,5 +1,6 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { import {
Group,
Input, Input,
darken, darken,
useMantineColorScheme, useMantineColorScheme,
@@ -15,9 +16,16 @@ import {
} from 'react-hook-form'; } from 'react-hook-form';
import Select from 'react-select'; import Select from 'react-select';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldType } from '@lib/types/Forms';
import { useApi } from '../../../contexts/ApiContext'; import { useApi } from '../../../contexts/ApiContext';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../../../states/SettingsStates';
import { vars } from '../../../theme'; import { vars } from '../../../theme';
import { ScanButton } from '../../buttons/ScanButton';
import Expand from '../../items/Expand';
import { RenderInstance } from '../../render/Instance'; import { RenderInstance } from '../../render/Instance';
/** /**
@@ -60,6 +68,101 @@ export function RelatedModelField({
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const dataRef = useRef<any[]>([]); const dataRef = useRef<any[]>([]);
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
// Search input query
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 250);
// Fetch a single field by primary key, using the provided API filters
const fetchSingleField = useCallback(
(pk: number) => {
if (!definition?.api_url) {
return;
}
const params = definition?.filters ?? {};
const url = `${definition.api_url}${pk}/`;
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
},
[
definition.api_url,
definition.filters,
definition.onValueChange,
definition.pk_field,
setValue,
setPk
]
);
// Memoize the model type information for this field
const modelInfo = useMemo(() => {
if (!definition.model) {
return null;
}
return ModelInformationDict[definition.model];
}, [definition.model]);
// Determine whether a barcode field should be added
const addBarcodeField: boolean = useMemo(() => {
if (!modelInfo || !modelInfo.supports_barcode) {
return false;
}
if (!globalSettings.isSet('BARCODE_ENABLE')) {
return false;
}
if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) {
return false;
}
return true;
}, [globalSettings, userSettings, modelInfo]);
// Callback function to handle barcode scan results
const onBarcodeScan = useCallback(
(barcode: string, response: any) => {
// Fetch model information from the response
const modelData = response?.[definition.model ?? ''] ?? null;
if (modelData) {
const pk_field = definition.pk_field ?? 'pk';
const pk = modelData[pk_field];
if (pk) {
// Perform a full re-fetch of the field data
// This is necessary as the barcode scan does not provide full data necessarily
fetchSingleField(pk);
}
}
},
[definition.model, definition.pk_field, fetchSingleField]
);
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [autoFilled, setAutoFilled] = useState<boolean>(false); const [autoFilled, setAutoFilled] = useState<boolean>(false);
@@ -144,37 +247,7 @@ export function RelatedModelField({
const id = pk || field.value; const id = pk || field.value;
if (id !== null && id !== undefined && id !== '') { if (id !== null && id !== undefined && id !== '') {
const url = `${definition.api_url}${id}/`; fetchSingleField(id);
if (!url) {
setPk(null);
return;
}
const params = definition?.filters ?? {};
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
} else { } else {
setPk(null); setPk(null);
} }
@@ -185,10 +258,6 @@ export function RelatedModelField({
field.value field.value
]); ]);
// Search input query
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 250);
const [filters, setFilters] = useState<any>({}); const [filters, setFilters] = useState<any>({});
const resetSearch = useCallback(() => { const resetSearch = useCallback(() => {
@@ -377,58 +446,68 @@ export function RelatedModelField({
error={definition.error ?? error?.message} error={definition.error ?? error?.message}
styles={{ description: { paddingBottom: '5px' } }} styles={{ description: { paddingBottom: '5px' } }}
> >
<Select <Group justify='space-between' wrap='nowrap' gap={3}>
id={fieldId} <Expand>
aria-label={`related-field-${field.name}`} <Select
value={currentValue} id={fieldId}
ref={field.ref} aria-label={`related-field-${field.name}`}
options={data} value={currentValue}
filterOption={null} ref={field.ref}
onInputChange={(value: any) => { options={data}
setValue(value); filterOption={null}
}} onInputChange={(value: any) => {
onChange={onChange} setValue(value);
onMenuScrollToBottom={() => setOffset(offset + limit)} }}
onMenuOpen={() => { onChange={onChange}
setIsOpen(true); onMenuScrollToBottom={() => setOffset(offset + limit)}
resetSearch(); onMenuOpen={() => {
selectQuery.refetch(); setIsOpen(true);
}} resetSearch();
onMenuClose={() => { selectQuery.refetch();
setIsOpen(false); }}
}} onMenuClose={() => {
isLoading={ setIsOpen(false);
selectQuery.isFetching || }}
selectQuery.isLoading || isLoading={
selectQuery.isRefetching selectQuery.isFetching ||
} selectQuery.isLoading ||
isClearable={!definition.required} selectQuery.isRefetching
isDisabled={definition.disabled}
isSearchable={true}
placeholder={definition.placeholder || `${t`Search`}...`}
loadingMessage={() => `${t`Loading`}...`}
menuPortalTarget={document.body}
noOptionsMessage={() => t`No results found`}
menuPosition='fixed'
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
clearIndicator: (base: any) => ({
...base,
color: 'red',
':hover': { color: 'red' }
})
}}
formatOptionLabel={(option: any) => formatOption(option)}
theme={(theme) => {
return {
...theme,
colors: {
...theme.colors,
...colors
} }
}; isClearable={!definition.required}
}} isDisabled={definition.disabled}
/> isSearchable={true}
placeholder={definition.placeholder || `${t`Search`}...`}
loadingMessage={() => `${t`Loading`}...`}
menuPortalTarget={document.body}
noOptionsMessage={() => t`No results found`}
menuPosition='fixed'
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
clearIndicator: (base: any) => ({
...base,
color: 'red',
':hover': { color: 'red' }
})
}}
formatOptionLabel={(option: any) => formatOption(option)}
theme={(theme) => {
return {
...theme,
colors: {
...theme.colors,
...colors
}
};
}}
/>
</Expand>
{addBarcodeField && (
<ScanButton
modelType={definition.model}
onScanSuccess={onBarcodeScan}
/>
)}
</Group>
</Input.Wrapper> </Input.Wrapper>
); );
} }

View File

@@ -59,6 +59,7 @@ export default function UserSettings() {
'STICKY_HEADER', 'STICKY_HEADER',
'STICKY_TABLE_HEADER', 'STICKY_TABLE_HEADER',
'SHOW_SPOTLIGHT', 'SHOW_SPOTLIGHT',
'BARCODE_IN_FORM_FIELDS',
'DATE_DISPLAY_FORMAT', 'DATE_DISPLAY_FORMAT',
'FORMS_CLOSE_USING_ESCAPE', 'FORMS_CLOSE_USING_ESCAPE',
'DISPLAY_STOCKTAKE_TAB', 'DISPLAY_STOCKTAKE_TAB',

View File

@@ -54,7 +54,11 @@ export const clearTableFilters = async (page: Page) => {
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
}; };
export const setTableChoiceFilter = async (page: Page, filter, value) => { export const setTableChoiceFilter = async (
page: Page,
filter: string,
value: string
) => {
await openFilterDrawer(page); await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click(); await page.getByRole('button', { name: 'Add Filter' }).click();
@@ -116,7 +120,7 @@ export const navigate = async (
/** /**
* CLick on the 'tab' element with the provided name * CLick on the 'tab' element with the provided name
*/ */
export const loadTab = async (page: Page, tabName, exact?) => { export const loadTab = async (page: Page, tabName: string, exact?: boolean) => {
await page await page
.getByLabel(/panel-tabs-/) .getByLabel(/panel-tabs-/)
.getByRole('tab', { name: tabName, exact: exact ?? false }) .getByRole('tab', { name: tabName, exact: exact ?? false })
@@ -140,7 +144,7 @@ export const activateCalendarView = async (page: Page) => {
/** /**
* Perform a 'global search' on the provided page, for the provided query text * Perform a 'global search' on the provided page, for the provided query text
*/ */
export const globalSearch = async (page: Page, query) => { export const globalSearch = async (page: Page, query: string) => {
await page.getByLabel('open-search').click(); await page.getByLabel('open-search').click();
await page.getByLabel('global-search-input').clear(); await page.getByLabel('global-search-input').clear();
await page.getByPlaceholder('Enter search text').fill(query); await page.getByPlaceholder('Enter search text').fill(query);

View File

@@ -14,7 +14,7 @@ interface LoginOptions {
/* /*
* Perform form based login operation from the "login" URL * Perform form based login operation from the "login" URL
*/ */
export const doLogin = async (page, options?: LoginOptions) => { export const doLogin = async (page: Page, options?: LoginOptions) => {
const username: string = options?.username ?? user.username; const username: string = options?.username ?? user.username;
const password: string = options?.password ?? user.password; const password: string = options?.password ?? user.password;

View File

@@ -225,7 +225,9 @@ test('Purchase Orders - Barcodes', async ({ browser }) => {
// Ensure we can scan back to this page, with the associated barcode // Ensure we can scan back to this page, with the associated barcode
await page.getByRole('tab', { name: 'Sales' }).click(); await page.getByRole('tab', { name: 'Sales' }).click();
await page.waitForTimeout(250); await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
await page.getByPlaceholder('Enter barcode data').fill('1234567890'); await page.getByPlaceholder('Enter barcode data').fill('1234567890');
await page.getByRole('button', { name: 'Scan', exact: true }).click(); await page.getByRole('button', { name: 'Scan', exact: true }).click();

View File

@@ -1,24 +1,37 @@
import type { Page } from '@playwright/test';
import { test } from '../baseFixtures'; import { test } from '../baseFixtures';
import { doCachedLogin } from '../login'; import { doCachedLogin } from '../login';
const scan = async (page, barcode) => { const scan = async (page: Page, barcode: string) => {
await page.getByLabel('barcode-input-scanner').click(); await page.getByLabel('barcode-input-scanner').click();
await page.getByLabel('barcode-scan-keyboard-input').fill(barcode); await page.getByLabel('barcode-scan-keyboard-input').fill(barcode);
await page.getByRole('button', { name: 'Scan', exact: true }).click(); await page.getByRole('button', { name: 'Scan', exact: true }).click();
}; };
test('Scanning - Dialog', async ({ browser }) => { test('Barcode Scanning - Dialog', async ({ browser }) => {
const page = await doCachedLogin(browser); const page = await doCachedLogin(browser);
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click(); // Attempt scan with invalid data
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
await scan(page, 'invalid-barcode-123');
await page.getByText('No match found for barcode').waitFor();
// Attempt scan with "legacy" barcode format
await scan(page, '{"part": 15}'); await scan(page, '{"part": 15}');
await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor(); await page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor();
await page.getByText('Available:').waitFor(); await page.getByText('Available:').waitFor();
await page.getByText('Required:').waitFor(); await page.getByText('Required:').waitFor();
// Attempt scan with "modern" barcode format
await page.getByRole('button', { name: 'barcode-scan-button-any' }).click();
await scan(page, 'INV-BO0010');
await page.getByText('Build Order: BO0010').waitFor();
await page.getByText('Making a high level assembly part').waitFor();
}); });
test('Scanning - Basic', async ({ browser }) => { test('Barcode Scanning - Basic', async ({ browser }) => {
const page = await doCachedLogin(browser); const page = await doCachedLogin(browser);
// Navigate to the 'scan' page // Navigate to the 'scan' page
@@ -39,7 +52,7 @@ test('Scanning - Basic', async ({ browser }) => {
await page.getByText('No match found for barcode').waitFor(); await page.getByText('No match found for barcode').waitFor();
}); });
test('Scanning - Part', async ({ browser }) => { test('Barcode Scanning - Part', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"part": 1}'); await scan(page, '{"part": 1}');
@@ -49,7 +62,7 @@ test('Scanning - Part', async ({ browser }) => {
await page.getByRole('cell', { name: 'part', exact: true }).waitFor(); await page.getByRole('cell', { name: 'part', exact: true }).waitFor();
}); });
test('Scanning - Stockitem', async ({ browser }) => { test('Barcode Scanning - Stockitem', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"stockitem": 408}'); await scan(page, '{"stockitem": 408}');
@@ -58,7 +71,7 @@ test('Scanning - Stockitem', async ({ browser }) => {
await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor(); await page.getByRole('cell', { name: 'Quantity: 100' }).waitFor();
}); });
test('Scanning - StockLocation', async ({ browser }) => { test('Barcode Scanning - StockLocation', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"stocklocation": 3}'); await scan(page, '{"stocklocation": 3}');
@@ -71,7 +84,7 @@ test('Scanning - StockLocation', async ({ browser }) => {
.waitFor(); .waitFor();
}); });
test('Scanning - SupplierPart', async ({ browser }) => { test('Barcode Scanning - SupplierPart', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"supplierpart": 204}'); await scan(page, '{"supplierpart": 204}');
@@ -80,7 +93,7 @@ test('Scanning - SupplierPart', async ({ browser }) => {
await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor(); await page.getByRole('cell', { name: 'supplierpart', exact: true }).waitFor();
}); });
test('Scanning - PurchaseOrder', async ({ browser }) => { test('Barcode Scanning - PurchaseOrder', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"purchaseorder": 12}'); await scan(page, '{"purchaseorder": 12}');
@@ -92,7 +105,7 @@ test('Scanning - PurchaseOrder', async ({ browser }) => {
.waitFor(); .waitFor();
}); });
test('Scanning - SalesOrder', async ({ browser }) => { test('Barcode Scanning - SalesOrder', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"salesorder": 6}'); await scan(page, '{"salesorder": 6}');
@@ -103,7 +116,7 @@ test('Scanning - SalesOrder', async ({ browser }) => {
await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor(); await page.getByRole('cell', { name: 'salesorder', exact: true }).waitFor();
}); });
test('Scanning - Build', async ({ browser }) => { test('Barcode Scanning - Build', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'scan/' }); const page = await doCachedLogin(browser, { url: 'scan/' });
await scan(page, '{"build": 8}'); await scan(page, '{"build": 8}');
@@ -112,3 +125,32 @@ test('Scanning - Build', async ({ browser }) => {
await page.getByText('PCBA build').waitFor(); await page.getByText('PCBA build').waitFor();
await page.getByRole('cell', { name: 'build', exact: true }).waitFor(); await page.getByRole('cell', { name: 'build', exact: true }).waitFor();
}); });
test('Barcode Scanning - Forms', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: '/stock/location/index/stock-items'
});
// Open the "Add Stock Item" form
await page
.getByRole('button', { name: 'action-button-add-stock-item' })
.click();
// Fill out the "part" data
await page.getByRole('button', { name: 'barcode-scan-button-part' }).click();
await page
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
.fill('INV-PA99');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Red Round Table').waitFor();
// Fill out the "location" data
await page
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
.click();
await page
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
.fill('INV-SL37');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Offsite Storage').waitFor();
});