diff --git a/docs/docs/assets/images/barcode/barcode_field.png b/docs/docs/assets/images/barcode/barcode_field.png new file mode 100644 index 0000000000..37683eb33e Binary files /dev/null and b/docs/docs/assets/images/barcode/barcode_field.png differ diff --git a/docs/docs/assets/images/barcode/barcode_field_dialog.png b/docs/docs/assets/images/barcode/barcode_field_dialog.png new file mode 100644 index 0000000000..202bf2fcd6 Binary files /dev/null and b/docs/docs/assets/images/barcode/barcode_field_dialog.png differ diff --git a/docs/docs/assets/images/barcode/barcode_field_filled.png b/docs/docs/assets/images/barcode/barcode_field_filled.png new file mode 100644 index 0000000000..208cfa4a3a Binary files /dev/null and b/docs/docs/assets/images/barcode/barcode_field_filled.png differ diff --git a/docs/docs/barcodes/index.md b/docs/docs/barcodes/index.md index 8a8eb2ab20..b9e53b38b6 100644 --- a/docs/docs/barcodes/index.md +++ b/docs/docs/barcodes/index.md @@ -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_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 @@ -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. 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) diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 4b32f972c0..d443084077 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -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. -#### 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. diff --git a/docs/docs/settings/user.md b/docs/docs/settings/user.md index 39871b25d3..faaa7aae8a 100644 --- a/docs/docs/settings/user.md +++ b/docs/docs/settings/user.md @@ -22,6 +22,7 @@ The *Display Settings* screen shows general display configuration options: {{ usersetting("STICKY_HEADER") }} {{ usersetting("STICKY_TABLE_HEADER") }} {{ usersetting("SHOW_SPOTLIGHT") }} +{{ usersetting("BARCODE_IN_FORM_FIELDS") }} {{ usersetting("DATE_DISPLAY_FORMAT") }} {{ usersetting("FORMS_CLOSE_USING_ESCAPE") }} {{ usersetting("DISPLAY_STOCKTAKE_TAB") }} diff --git a/src/backend/InvenTree/common/setting/user.py b/src/backend/InvenTree/common/setting/user.py index 793f9bae1b..b318a00213 100644 --- a/src/backend/InvenTree/common/setting/user.py +++ b/src/backend/InvenTree/common/setting/user.py @@ -41,6 +41,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, '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': { 'name': _('Search Parts'), 'description': _('Display parts in search preview window'), diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index b529e2c49a..6f93a1ebeb 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -10,6 +10,7 @@ export interface ModelInformationInterface { url_detail?: string; api_endpoint: ApiEndpoints; admin_url?: string; + supports_barcode?: boolean; icon: keyof InvenTreeIconType; } @@ -31,6 +32,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/part/:pk/', api_endpoint: ApiEndpoints.part_list, admin_url: '/part/part/', + supports_barcode: true, icon: 'part' }, parameter: { @@ -60,6 +62,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/purchasing/supplier-part/:pk/', api_endpoint: ApiEndpoints.supplier_part_list, admin_url: '/company/supplierpart/', + supports_barcode: true, icon: 'supplier_part' }, manufacturerpart: { @@ -69,6 +72,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/purchasing/manufacturer-part/:pk/', api_endpoint: ApiEndpoints.manufacturer_part_list, admin_url: '/company/manufacturerpart/', + supports_barcode: true, icon: 'manufacturers' }, partcategory: { @@ -87,6 +91,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/stock/item/:pk/', api_endpoint: ApiEndpoints.stock_item_list, admin_url: '/stock/stockitem/', + supports_barcode: true, icon: 'stock' }, stocklocation: { @@ -96,6 +101,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/stock/location/:pk/', api_endpoint: ApiEndpoints.stock_location_list, admin_url: '/stock/stocklocation/', + supports_barcode: true, icon: 'location' }, stocklocationtype: { @@ -117,6 +123,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/manufacturing/build-order/:pk/', api_endpoint: ApiEndpoints.build_order_list, admin_url: '/build/build/', + supports_barcode: true, icon: 'build_order' }, buildline: { @@ -155,6 +162,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/purchasing/purchase-order/:pk/', api_endpoint: ApiEndpoints.purchase_order_list, admin_url: '/order/purchaseorder/', + supports_barcode: true, icon: 'purchase_orders' }, purchaseorderlineitem: { @@ -170,6 +178,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/sales/sales-order/:pk/', api_endpoint: ApiEndpoints.sales_order_list, admin_url: '/order/salesorder/', + supports_barcode: true, icon: 'sales_orders' }, salesordershipment: { @@ -178,6 +187,7 @@ export const ModelInformationDict: ModelDict = { url_overview: '/sales/index/shipments', url_detail: '/sales/shipment/:pk/', api_endpoint: ApiEndpoints.sales_order_shipment_list, + supports_barcode: true, icon: 'shipment' }, returnorder: { @@ -187,6 +197,7 @@ export const ModelInformationDict: ModelDict = { url_detail: '/sales/return-order/:pk/', api_endpoint: ApiEndpoints.return_order_list, admin_url: '/order/returnorder/', + supports_barcode: true, icon: 'return_orders' }, returnorderlineitem: { diff --git a/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx b/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx index 3457d55534..6e2eb50640 100644 --- a/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx +++ b/src/frontend/src/components/barcodes/BarcodeScanDialog.tsx @@ -19,6 +19,11 @@ export type BarcodeScanResult = { error?: string; }; +export type BarcodeScanSuccessCallback = ( + barcode: string, + response: any +) => void; + // Callback function for handling a barcode scan // This function should return true if the barcode was handled successfully export type BarcodeScanCallback = ( @@ -31,13 +36,15 @@ export default function BarcodeScanDialog({ opened, callback, modelType, - onClose + onClose, + onScanSuccess }: Readonly<{ title?: string; opened: boolean; modelType?: ModelType; callback?: BarcodeScanCallback; onClose: () => void; + onScanSuccess?: BarcodeScanSuccessCallback; }>) { const navigate = useNavigate(); @@ -53,6 +60,7 @@ export default function BarcodeScanDialog({ @@ -65,10 +73,12 @@ export function ScanInputHandler({ callback, modelType, onClose, + onScanSuccess, navigate }: Readonly<{ callback?: BarcodeScanCallback; onClose: () => void; + onScanSuccess?: BarcodeScanSuccessCallback; modelType?: ModelType; navigate: NavigateFunction; }>) { @@ -94,7 +104,13 @@ export function ScanInputHandler({ data[model_type]['pk'] ); onClose(); - navigate(url); + + if (onScanSuccess) { + onScanSuccess(data['barcode'], data); + } else { + navigate(url); + } + match = true; break; } diff --git a/src/frontend/src/components/buttons/ScanButton.tsx b/src/frontend/src/components/buttons/ScanButton.tsx index 46676b6033..542656aed1 100644 --- a/src/frontend/src/components/buttons/ScanButton.tsx +++ b/src/frontend/src/components/buttons/ScanButton.tsx @@ -1,19 +1,32 @@ +import type { ModelType } from '@lib/index'; import { t } from '@lingui/core/macro'; import { ActionIcon, Tooltip } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; 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 */ -export function ScanButton() { +export function ScanButton({ + modelType, + callback, + onScanSuccess +}: { + modelType?: ModelType; + callback?: BarcodeScanCallback; + onScanSuccess?: BarcodeScanSuccessCallback; +}) { const [opened, { open, close }] = useDisclosure(false); return ( <> - + ); } diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index d66fe76aa9..85398bac35 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/core/macro'; import { + Group, Input, darken, useMantineColorScheme, @@ -15,9 +16,16 @@ import { } from 'react-hook-form'; import Select from 'react-select'; +import { ModelInformationDict } from '@lib/enums/ModelInformation'; import type { ApiFormFieldType } from '@lib/types/Forms'; import { useApi } from '../../../contexts/ApiContext'; +import { + useGlobalSettingsState, + useUserSettingsState +} from '../../../states/SettingsStates'; import { vars } from '../../../theme'; +import { ScanButton } from '../../buttons/ScanButton'; +import Expand from '../../items/Expand'; import { RenderInstance } from '../../render/Instance'; /** @@ -60,6 +68,101 @@ export function RelatedModelField({ const [data, setData] = useState([]); const dataRef = useRef([]); + const globalSettings = useGlobalSettingsState(); + const userSettings = useUserSettingsState(); + + // Search input query + const [value, setValue] = useState(''); + 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(false); const [autoFilled, setAutoFilled] = useState(false); @@ -144,37 +247,7 @@ export function RelatedModelField({ const id = pk || field.value; if (id !== null && id !== undefined && id !== '') { - const url = `${definition.api_url}${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]); - } - }); + fetchSingleField(id); } else { setPk(null); } @@ -185,10 +258,6 @@ export function RelatedModelField({ field.value ]); - // Search input query - const [value, setValue] = useState(''); - const [searchText] = useDebouncedValue(value, 250); - const [filters, setFilters] = useState({}); const resetSearch = useCallback(() => { @@ -377,58 +446,68 @@ export function RelatedModelField({ error={definition.error ?? error?.message} styles={{ description: { paddingBottom: '5px' } }} > - { + setValue(value); + }} + onChange={onChange} + onMenuScrollToBottom={() => setOffset(offset + limit)} + onMenuOpen={() => { + setIsOpen(true); + resetSearch(); + selectQuery.refetch(); + }} + onMenuClose={() => { + setIsOpen(false); + }} + isLoading={ + selectQuery.isFetching || + selectQuery.isLoading || + selectQuery.isRefetching } - }; - }} - /> + 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 + } + }; + }} + /> + + {addBarcodeField && ( + + )} + ); } diff --git a/src/frontend/src/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx index 0bc5837fb1..55a62ede4b 100644 --- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx @@ -59,6 +59,7 @@ export default function UserSettings() { 'STICKY_HEADER', 'STICKY_TABLE_HEADER', 'SHOW_SPOTLIGHT', + 'BARCODE_IN_FORM_FIELDS', 'DATE_DISPLAY_FORMAT', 'FORMS_CLOSE_USING_ESCAPE', 'DISPLAY_STOCKTAKE_TAB', diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index 36a14672d5..a7dfed6e8a 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -54,7 +54,11 @@ export const clearTableFilters = async (page: Page) => { 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 page.getByRole('button', { name: 'Add Filter' }).click(); @@ -116,7 +120,7 @@ export const navigate = async ( /** * 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 .getByLabel(/panel-tabs-/) .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 */ -export const globalSearch = async (page: Page, query) => { +export const globalSearch = async (page: Page, query: string) => { await page.getByLabel('open-search').click(); await page.getByLabel('global-search-input').clear(); await page.getByPlaceholder('Enter search text').fill(query); diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts index 7571b7dd1e..b4a647a878 100644 --- a/src/frontend/tests/login.ts +++ b/src/frontend/tests/login.ts @@ -14,7 +14,7 @@ interface LoginOptions { /* * 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 password: string = options?.password ?? user.password; diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 1eb5be4001..2ce5412e13 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -225,7 +225,9 @@ test('Purchase Orders - Barcodes', async ({ browser }) => { // Ensure we can scan back to this page, with the associated barcode await page.getByRole('tab', { name: 'Sales' }).click(); 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.getByRole('button', { name: 'Scan', exact: true }).click(); diff --git a/src/frontend/tests/pages/pui_scan.spec.ts b/src/frontend/tests/pages/pui_scan.spec.ts index 78c1e1fee8..139e122724 100644 --- a/src/frontend/tests/pages/pui_scan.spec.ts +++ b/src/frontend/tests/pages/pui_scan.spec.ts @@ -1,24 +1,37 @@ +import type { Page } from '@playwright/test'; import { test } from '../baseFixtures'; 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-scan-keyboard-input').fill(barcode); 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); - 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 page.getByText('Part: R_550R_0805_1%', { exact: true }).waitFor(); await page.getByText('Available:').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); // Navigate to the 'scan' page @@ -39,7 +52,7 @@ test('Scanning - Basic', async ({ browser }) => { 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/' }); await scan(page, '{"part": 1}'); @@ -49,7 +62,7 @@ test('Scanning - Part', async ({ browser }) => { 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/' }); await scan(page, '{"stockitem": 408}'); @@ -58,7 +71,7 @@ test('Scanning - Stockitem', async ({ browser }) => { 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/' }); await scan(page, '{"stocklocation": 3}'); @@ -71,7 +84,7 @@ test('Scanning - StockLocation', async ({ browser }) => { .waitFor(); }); -test('Scanning - SupplierPart', async ({ browser }) => { +test('Barcode Scanning - SupplierPart', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'scan/' }); await scan(page, '{"supplierpart": 204}'); @@ -80,7 +93,7 @@ test('Scanning - SupplierPart', async ({ browser }) => { 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/' }); await scan(page, '{"purchaseorder": 12}'); @@ -92,7 +105,7 @@ test('Scanning - PurchaseOrder', async ({ browser }) => { .waitFor(); }); -test('Scanning - SalesOrder', async ({ browser }) => { +test('Barcode Scanning - SalesOrder', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'scan/' }); await scan(page, '{"salesorder": 6}'); @@ -103,7 +116,7 @@ test('Scanning - SalesOrder', async ({ browser }) => { 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/' }); await scan(page, '{"build": 8}'); @@ -112,3 +125,32 @@ test('Scanning - Build', async ({ browser }) => { await page.getByText('PCBA build').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(); +});