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