diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 64be85b563..0a81d00f64 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1770,7 +1770,7 @@ class StockCountSerializer(StockAdjustmentSerializer): fields = ['items', 'notes', 'location'] location = serializers.PrimaryKeyRelatedField( - queryset=StockLocation.objects.filter(structural=False), + queryset=StockLocation.objects.filter(), many=False, required=False, allow_null=True, @@ -1778,6 +1778,15 @@ class StockCountSerializer(StockAdjustmentSerializer): help_text=_('Set stock location for counted items (optional)'), ) + def validate_location(self, location): + """Validate the provided location.""" + if location and location.structural: + raise ValidationError( + _('Structural locations cannot be assigned stock items') + ) + + return location + def save(self): """Count stock.""" request = self.context['request'] @@ -1874,7 +1883,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): items = StockAdjustmentItemSerializer(many=True, require_non_zero=True) location = serializers.PrimaryKeyRelatedField( - queryset=StockLocation.objects.filter(structural=False), + queryset=StockLocation.objects.filter(), many=False, required=True, allow_null=False, @@ -1882,6 +1891,15 @@ class StockTransferSerializer(StockAdjustmentSerializer): help_text=_('Destination stock location'), ) + def validate_location(self, location): + """Validate the provided location.""" + if location and location.structural: + raise ValidationError( + _('Structural locations cannot be assigned stock items') + ) + + return location + def save(self): """Transfer stock.""" request = self.context['request'] @@ -1965,7 +1983,7 @@ class StockReturnSerializer(StockAdjustmentSerializer): ) location = serializers.PrimaryKeyRelatedField( - queryset=StockLocation.objects.filter(structural=False), + queryset=StockLocation.objects.filter(), many=False, required=True, allow_null=False, @@ -1973,6 +1991,15 @@ class StockReturnSerializer(StockAdjustmentSerializer): help_text=_('Destination stock location'), ) + def validate_location(self, location): + """Validate the provided location.""" + if location and location.structural: + raise ValidationError( + _('Structural locations cannot be assigned stock items') + ) + + return location + merge = serializers.BooleanField( default=False, required=False, diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 9a42e4beda..5fe46e689f 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2435,7 +2435,10 @@ class StocktakeTest(StockAPITestCase): {'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk}, expected_code=400, ) - self.assertIn('does not exist', str(response.data['location'])) + self.assertIn( + 'Structural locations cannot be assigned stock items', + str(response.data['location']), + ) class StockTransferMergeTest(StockAPITestCase): diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index d9adc749f3..1d76affc79 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -4,6 +4,9 @@ import { useId } from '@mantine/hooks'; import { useCallback, useEffect, useMemo } from 'react'; import { type Control, type FieldValues, useController } from 'react-hook-form'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import { IconFileUpload } from '@tabler/icons-react'; import type { NavigateFunction } from 'react-router-dom'; @@ -19,6 +22,7 @@ import { RelatedModelField } from './RelatedModelField'; import { TableField } from './TableField'; import TagsField from './TagsField'; import TextField from './TextField'; +import { TreeField } from './TreeField'; /** * Render an individual form field @@ -121,14 +125,48 @@ export function ApiFormField({ const fieldInstance = useMemo(() => { switch (fieldDefinition.field_type) { case 'related field': - return ( - - ); + if ( + fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list) + ) { + // Redirect location fields to the appropriate tree field + return ( + + ); + } else if ( + fieldDefinition.api_url === apiUrl(ApiEndpoints.category_list) + ) { + // Redirect category fields to the appropriate tree field + return ( + + ); + } else { + return ( + + ); + } case 'email': case 'url': case 'string': diff --git a/src/frontend/src/components/forms/fields/TreeField.tsx b/src/frontend/src/components/forms/fields/TreeField.tsx new file mode 100644 index 0000000000..4ff583f395 --- /dev/null +++ b/src/frontend/src/components/forms/fields/TreeField.tsx @@ -0,0 +1,499 @@ +import { t } from '@lingui/core/macro'; +import { + ActionIcon, + Group, + Input, + Text, + type TreeNodeData, + TreeSelect +} from '@mantine/core'; +import { useDebouncedValue, useId } from '@mantine/hooks'; +import { + IconChevronDown, + IconChevronRight, + IconLink, + IconX +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { FieldValues, UseControllerReturn } from 'react-hook-form'; +import type { NavigateFunction } from 'react-router-dom'; + +import type { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import type { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; +import { cancelEvent } from '@lib/functions/Events'; +import { getDetailUrl, navigateToLink } from '@lib/index'; +import type { ApiFormFieldType } from '@lib/types/Forms'; +import { useApi } from '../../../contexts/ApiContext'; +import { + useGlobalSettingsState, + useUserSettingsState +} from '../../../states/SettingsStates'; +import { ScanButton } from '../../buttons/ScanButton'; +import { ApiIcon } from '../../items/ApiIcon'; +import Expand from '../../items/Expand'; +import { ModelHoverCard } from '../../render/ModelHoverCard'; + +/** + * A form field that renders a hierarchical tree selector backed by a tree API + * endpoint. Supports server-side search, lazy child loading, and (when a model + * type is provided) barcode scanning and a hover-card navigate link. + */ +export function TreeField({ + controller, + definition, + fieldName, + endpoint, + childIdentifier, + genericPlaceholder, + model, + navigate +}: Readonly<{ + controller: UseControllerReturn; + definition: ApiFormFieldType; + fieldName: string; + endpoint: ApiEndpoints; + childIdentifier: string; + genericPlaceholder?: string; + model?: ModelType; + navigate?: NavigateFunction | null; +}>) { + const api = useApi(); + const inputId = useId(); + const globalSettings = useGlobalSettingsState(); + const userSettings = useUserSettingsState(); + + const { + field, + fieldState: { error } + } = controller; + + // Keep the selected pk in sync with form state so we can always request + // the selected node (and its ancestors) for label hydration. + const selectedValue = useMemo( + () => (field.value != null ? Number(field.value) : null), + [field.value] + ); + + // Track dropdown state to separate server-side search text from the + // read-only display label shown when the field is closed. + const dropdownOpen = useRef(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // Track the pk whose ancestor path was last auto-expanded. + const expandedForValue = useRef(null); + + const [searchValue, setSearchValue] = useState(''); + const [debouncedSearch] = useDebouncedValue(searchValue, 300); + const [expandedValues, setExpandedValues] = useState([]); + const [nodes, setNodes] = useState([]); + + const query = useQuery({ + enabled: + !definition.disabled && + !definition.hidden && + (isDropdownOpen || selectedValue != null), + queryKey: [ + 'tree-field', + fieldName, + endpoint, + isDropdownOpen, + debouncedSearch, + selectedValue + ], + queryFn: () => + api + .get(apiUrl(endpoint), { + params: { + ...definition.filters, + ordering: 'level', + search: debouncedSearch || undefined, + // Include the selected node and its ancestors in the initial response + // so the node label is available before the user interacts with the field. + max_level: debouncedSearch ? undefined : 0, + expand_to: debouncedSearch + ? undefined + : (selectedValue ?? undefined) + } + }) + .then((res) => res.data ?? []) + }); + + useEffect(() => { + setNodes(query.data ?? []); + }, [query.data]); + + // O(1) lookup for raw node data inside renderNode + const nodeMap = useMemo(() => { + const map: Record = {}; + for (const n of nodes) map[n.pk.toString()] = n; + return map; + }, [nodes]); + + // Expand all returned nodes when a search is active so users can see all matches. + // On the first browse-mode load, expand the ancestors of the initial value so + // the tree shows the path to the currently-selected node. + useEffect(() => { + if (debouncedSearch) { + setExpandedValues(nodes.map((n: any) => n.pk.toString())); + return; + } + + if ( + selectedValue != null && + expandedForValue.current !== selectedValue && + nodes.length > 0 + ) { + expandedForValue.current = selectedValue; + const map: Record = {}; + for (const n of nodes) map[n.pk] = n; + const toExpand: string[] = []; + let cur = map[selectedValue]; + while (cur?.parent) { + toExpand.push(String(cur.parent)); + cur = map[cur.parent]; + } + setExpandedValues(toExpand); + return; + } + + if (selectedValue == null) { + expandedForValue.current = null; + } + }, [debouncedSearch, nodes, selectedValue]); + + // Convert the flat API response (sorted by level) into the nested TreeNodeData structure. + // `children` is intentionally left undefined on leaf nodes: Mantine's flatten logic uses + // Array.isArray(node.children) to detect loaded children, so an empty [] would make every + // node look like a parent. Instead we set node.hasChildren from the server-side count field + // (childIdentifier) and only attach a children array when a child is actually encountered. + const treeData: TreeNodeData[] = useMemo(() => { + const map: Record = {}; + const tree: TreeNodeData[] = []; + + const sorted = [...nodes].sort((a, b) => a.level - b.level); + + for (const raw of sorted) { + const node: any = { + value: raw.pk.toString(), + label: raw.name as string, + hasChildren: (raw[childIdentifier] ?? 0) > 0 + }; + + map[raw.pk] = node; + + if (!raw.parent) { + tree.push(node); + } else if (map[raw.parent]) { + if (!map[raw.parent].children) { + map[raw.parent].children = []; + } + map[raw.parent].children.push(node); + } else { + // Keep orphaned nodes visible so selected labels can still resolve + // if the API response omits an ancestor. + tree.push(node); + } + } + return tree; + }, [nodes, childIdentifier]); + + const onChange = useCallback( + (val: string | null) => { + const pk = val ? Number.parseInt(val, 10) : null; + const raw = val ? (nodeMap[val] ?? {}) : {}; + field.onChange(pk); + definition.onValueChange?.(pk, raw); + }, + [field, definition, nodeMap] + ); + + const selectValue = useMemo( + () => (field.value != null ? field.value.toString() : null), + [field.value] + ); + + const selectedLabel = useMemo(() => { + if (selectValue == null) return ''; + const node = nodeMap[selectValue]; + return node?.pathstring ?? node?.name ?? selectValue; + }, [nodeMap, selectValue]); + + const inputSearchValue = isDropdownOpen ? searchValue : selectedLabel; + + const refreshChildren = useCallback( + async (nodeValue: string) => { + const pk = Number.parseInt(nodeValue, 10); + if (Number.isNaN(pk)) return; + + const node = nodeMap[nodeValue]; + if (!node) return; + + const response = await api.get(apiUrl(endpoint), { + params: { + ...definition.filters, + ordering: 'level', + parent: pk, + max_level: (node.level ?? 0) + 1 + } + }); + + const children: any[] = response.data ?? []; + + setNodes((prev) => { + const base = prev.filter((n) => n.parent !== pk); + const byPk = new Map(); + + for (const n of base) { + byPk.set(n.pk, n); + } + + for (const child of children) { + byPk.set(child.pk, child); + } + + if (children.length === 0) { + const parentNode = byPk.get(pk); + if (parentNode) { + byPk.set(pk, { ...parentNode, [childIdentifier]: 0 }); + } + } + + return Array.from(byPk.values()); + }); + }, + [api, endpoint, nodeMap, childIdentifier] + ); + + const toggleExpanded = useCallback( + (nodeValue: string) => { + setExpandedValues((prev) => { + const isExpanded = prev.includes(nodeValue); + + if (!isExpanded) { + void refreshChildren(nodeValue); + return [...prev, nodeValue]; + } + + return prev.filter((v) => v !== nodeValue); + }); + }, + [refreshChildren] + ); + + // --- Navigate / hovercard --- + + const detailUrl = useMemo(() => { + if (!model || !selectedValue) return ''; + return getDetailUrl(model, selectedValue, true); + }, [model, selectedValue]); + + const handleNavigate = useCallback( + (e: any) => { + if (navigate && detailUrl) navigateToLink(detailUrl, navigate, e); + }, + [navigate, detailUrl] + ); + + // When a navigate model is present and a value is selected, swap out + // Mantine's built-in clear button for a custom right section that holds + // both a clear button and a link to the model detail page (with tooltip). + const showNavigateSection = Boolean(model && selectedValue); + + const navigateRightSection = showNavigateSection ? ( + + {!definition.required && selectValue && ( + { + cancelEvent(e); + onChange(null); + }} + > + + + )} + + + + + + + ) : undefined; + + // --- Barcode scanning --- + + const modelInfo = useMemo( + () => (model ? ModelInformationDict[model] : null), + [model] + ); + + const addBarcodeField = useMemo(() => { + if (!modelInfo?.supports_barcode) return false; + if (!globalSettings.isSet('BARCODE_ENABLE')) return false; + if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) return false; + return true; + }, [modelInfo, globalSettings, userSettings]); + + const onBarcodeScan = useCallback( + (_barcode: string, response: any) => { + if (!model) return; + const modelData = response?.[model] ?? null; + if (modelData?.pk) { + field.onChange(modelData.pk); + definition.onValueChange?.(modelData.pk, modelData); + } + }, + [model, field, definition] + ); + + // --- Render --- + + return ( + + + + { + if (dropdownOpen.current) setSearchValue(val); + }} + searchable + filter={() => true} + clearable={showNavigateSection ? false : !definition.required} + rightSection={navigateRightSection} + rightSectionPointerEvents={showNavigateSection ? 'all' : undefined} + rightSectionWidth={ + showNavigateSection ? (definition.required ? 28 : 52) : undefined + } + expandedValues={expandedValues} + onExpandedChange={setExpandedValues} + onDropdownOpen={() => { + dropdownOpen.current = true; + setIsDropdownOpen(true); + }} + onDropdownClose={() => { + dropdownOpen.current = false; + setIsDropdownOpen(false); + setSearchValue(''); + }} + placeholder={ + isDropdownOpen && selectedLabel + ? selectedLabel + : (definition.placeholder ?? genericPlaceholder ?? t`Select...`) + } + disabled={definition.disabled} + comboboxProps={{ withinPortal: true }} + maxDropdownHeight={300} + nothingFoundMessage={ + query.isFetching ? t`Loading...` : t`No results found` + } + renderNode={({ node, expanded, hasChildren, selected }) => { + const raw = nodeMap[node.value]; + return ( + + + {/* Chevron rendered manually so renderNode can coexist with + expand behavior. stopPropagation prevents the + Combobox.Option from selecting the node when the user + clicks the expand toggle. */} + { + cancelEvent(event); + toggleExpanded(node.value); + } + : undefined + } + onKeyDown={ + hasChildren + ? (event: any) => { + if (event.key === 'Enter' || event.key === ' ') { + cancelEvent(event); + toggleExpanded(node.value); + } + } + : undefined + } + > + {hasChildren && + (expanded ? ( + + ) : ( + + ))} + + {raw?.icon && } + + {raw?.name ?? String(node.label)} + + + {raw?.description && ( + + {raw.description} + + )} + + ); + }} + /> + + {addBarcodeField && ( + + )} + + + ); +} diff --git a/src/frontend/src/components/render/ModelHoverCard.tsx b/src/frontend/src/components/render/ModelHoverCard.tsx new file mode 100644 index 0000000000..6de4dd694b --- /dev/null +++ b/src/frontend/src/components/render/ModelHoverCard.tsx @@ -0,0 +1,81 @@ +import { t } from '@lingui/core/macro'; +import { + ActionIcon, + Anchor, + Group, + HoverCard, + Stack, + Text +} from '@mantine/core'; +import type { ReactNode } from 'react'; +import type { NavigateFunction } from 'react-router-dom'; + +import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import type { ModelType } from '@lib/enums/ModelType'; +import { getDetailUrl, navigateToLink } from '@lib/index'; +import { IconLink } from '@tabler/icons-react'; + +/** + * Wraps children in a HoverCard showing the model label, pk, and a "View + * details" link for the given instance. Renders children directly (no card) + * when model or pk is absent. + */ +export function ModelHoverCard({ + children, + model, + pk, + navigate +}: { + children: ReactNode; + model: ModelType | undefined; + pk: number | null | undefined; + navigate?: NavigateFunction | null; +}) { + const modelInfo = model ? ModelInformationDict[model] : undefined; + + if (!modelInfo || !pk) { + return <>{children}; + } + + const detailUrl = getDetailUrl(model!, pk, true); + + return ( + + + {children} + + + + + + {modelInfo.label()} + + {`[${t`ID`}: ${pk}]`} + + {detailUrl && ( + { + if (navigate) navigateToLink(detailUrl, navigate, event); + }} + > + + + + + {t`View details`} + + + )} + + + + ); +} diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index 9edb9bf68c..cd27bba85d 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -128,12 +128,7 @@ export function RenderPartCategory( - {instance.level > 0 && `${'- '.repeat(instance.level)}`} - {instance.icon && } - - } + prefix={instance.icon && } primary={category} suffix={suffix} url={ diff --git a/src/frontend/src/components/render/Stock.tsx b/src/frontend/src/components/render/Stock.tsx index 1ecd89809b..196eed5de1 100644 --- a/src/frontend/src/components/render/Stock.tsx +++ b/src/frontend/src/components/render/Stock.tsx @@ -48,12 +48,7 @@ export function RenderStockLocation( - {instance.level > 0 && `${'- '.repeat(instance.level)}`} - {instance.icon && } - - } + prefix={instance.icon && } primary={location} suffix={suffix} url={ diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 319554f717..c5931873c8 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -227,8 +227,10 @@ test('Build Order - Calendar', async ({ browser }) => { await page .getByRole('option', { name: 'Part Category', exact: true }) .click(); - await page.getByLabel('related-field-filter-category').click(); - await page.getByText('Part category, level 1').waitFor(); + await page.getByLabel('tree-field-filter-category').click(); + await page.getByText('Part category, level 1').click(); + await page.getByText('Filter by part category').waitFor(); + await page.getByText('Category 0').first().waitFor(); // Required because we downloaded a file await page.context().close(); @@ -324,9 +326,9 @@ test('Build Order - Build Outputs', async ({ browser }) => { .getByRole('img', { name: 'field-batch_code-accept-placeholder' }) .click(); - await page.getByLabel('related-field-location').click(); - await page.getByLabel('related-field-location').fill('Reel'); - await page.getByText('- Electronics Lab/Reel Storage').click(); + await page.getByLabel('tree-field-location').click(); + await page.getByLabel('tree-field-location').fill('Reel'); + await page.getByText('Storage for component reels').click(); await page.getByRole('button', { name: 'Submit' }).click(); // Should be an error as the number of serial numbers doesn't match the quantity @@ -361,9 +363,10 @@ test('Build Order - Build Outputs', async ({ browser }) => { const row2 = await getRowFromCell(cell2); await row2.getByLabel(/row-action-menu-/i).click(); await page.getByRole('menuitem', { name: 'Complete' }).click(); - await page.getByLabel('related-field-location').click(); + await page.getByLabel('tree-field-location').click(); + await page.getByLabel('tree-field-location').fill('Mechanical'); await page.getByText('Mechanical Lab').click(); - await page.waitForTimeout(250); + await page.waitForTimeout(100); await page.getByRole('button', { name: 'Submit' }).click(); await page.getByText('Build outputs have been completed').waitFor(); @@ -385,10 +388,8 @@ test('Build Order - Build Outputs', async ({ browser }) => { // Next, adjust the "location" field - and check that the "quantity" field does not change // Ref: https://github.com/inventree/InvenTree/pull/12081 - await page - .getByRole('combobox', { name: 'related-field-location' }) - .fill('factory'); - await page.getByTitle('Factory/Mechanical Lab').click(); + await page.getByLabel('tree-field-location').fill('mechanical'); + await page.getByText('Mechanical Lab').click(); await page.waitForTimeout(250); // Check the 'quantity' value again - it should not have changed @@ -539,11 +540,7 @@ test('Build Order - Auto Allocate Tracked', async ({ browser }) => { .click(); // Wait for auto-filled form field - await page - .locator('div') - .filter({ hasText: /^Factory$/ }) - .first() - .waitFor(); + await page.getByText('Factory').waitFor(); await page.getByRole('button', { name: 'Submit' }).click(); // Wait for one of the required parts to be allocated diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 32bfc2f456..08a4d9f86b 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -973,7 +973,7 @@ test('Parts - Notes', async ({ browser }) => { await page.keyboard.press('Control+E'); await page.getByLabel('text-field-name', { exact: true }).waitFor(); await page.getByLabel('text-field-description', { exact: true }).waitFor(); - await page.getByLabel('related-field-category').waitFor(); + await page.getByLabel('tree-field-category').waitFor(); await page.getByRole('button', { name: 'Cancel' }).click(); // Enable notes editing @@ -1024,10 +1024,8 @@ test('Parts - Bulk Edit', async ({ browser }) => { await openDetailAction(page, 'part', 'set-category'); - await page.getByLabel('related-field-category').fill('rnitu'); - await page.waitForTimeout(250); - - await page.getByRole('option', { name: '- Furniture/Chairs' }).click(); + await page.getByLabel('tree-field-category').fill('rnitu'); + await page.getByText('Furniture and associated').click(); await page.getByRole('button', { name: 'Update' }).click(); await page.getByText('Items Updated').waitFor(); }); diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts index 8b88a7c5bc..f4a8bd28fb 100644 --- a/src/frontend/tests/pages/pui_purchasing.spec.ts +++ b/src/frontend/tests/pages/pui_purchasing.spec.ts @@ -512,15 +512,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { await page.getByLabel('action-button-receive-items').click(); // Check for display of individual locations - await page - .getByRole('cell', { name: /Choose Location/ }) - .getByText('Parts Bins') - .waitFor(); - await page - .getByRole('cell', { name: /Choose Location/ }) - .getByText('Room 101') - .waitFor(); - + await page.getByText('Parts Bins').first().waitFor(); + await page.getByText('Room 101').first().waitFor(); await page.getByText('Mechanical Lab').first().waitFor(); await page.getByRole('button', { name: 'Cancel' }).click(); @@ -549,8 +542,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { await page.getByRole('menuitem', { name: 'Receive line item' }).click(); // Select destination location - await page.getByLabel('related-field-location').click(); - await page.getByRole('option', { name: 'Factory', exact: true }).click(); + await page.getByLabel('tree-field-location').fill('factory'); + await page.getByText('Factory', { exact: true }).click(); // Receive only a *single* item await page.getByLabel('number-field-quantity').fill('1'); @@ -611,10 +604,8 @@ test('Purchase Orders - Receive Virtual Items', async ({ browser }) => { .getByRole('button', { name: 'action-button-receive-items' }) .click(); - await page - .getByRole('combobox', { name: 'related-field-location' }) - .fill('factory'); - await page.getByText('Factory/Storage Room A').click(); + await page.getByLabel('tree-field-location').fill('factory'); + await page.getByText('Factory', { exact: true }).click(); await page.getByRole('button', { name: 'Submit' }).click(); diff --git a/src/frontend/tests/pages/pui_scan.spec.ts b/src/frontend/tests/pages/pui_scan.spec.ts index 97c8a7328f..8a59b1845a 100644 --- a/src/frontend/tests/pages/pui_scan.spec.ts +++ b/src/frontend/tests/pages/pui_scan.spec.ts @@ -177,6 +177,7 @@ test('Barcode Scanning - Forms', async ({ browser }) => { await page .getByRole('button', { name: 'barcode-scan-button-stocklocation' }) .click(); + await page .getByRole('textbox', { name: 'barcode-scan-keyboard-input' }) .fill('INV-SL37'); diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index bcf8d64d72..7a08db7bbc 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -499,7 +499,8 @@ test('Stock - Tracking', async ({ browser }) => { // Navigate to the "stock tracking" tab await loadTab(page, 'Stock Tracking'); - await page.getByText('- - Factory/Office Block/Room').first().waitFor(); + + await page.getByText('Factory/Office Block/Room').first().waitFor(); await page.getByRole('link', { name: 'Widget Assembly' }).waitFor(); await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor(); diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index ee00dc7abc..692b26351c 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -76,7 +76,7 @@ test('Forms - Stock Item Validation', async ({ browser }) => { .waitFor(); // Set location - await page.getByLabel('related-field-location').click(); + await page.getByLabel('tree-field-location').fill('production'); await page.getByText('Electronics production facility').click(); // Create the stock item