2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

[UI] Tree select in form fields (#12217)

* Add TreeField component

* Add chevrons in dropdown menu

* Custom node rendering

* Add childIdentifier

* Ensure full value gets displayed

* Fix for search results

* Refactor old renderers

* Override field types

* use definition filters

* Better location validation messages

* Tweaks

* Fix unit test

* Include icon

* Fix playwright test

* Fix call to onValueChange

* Updated playwright tests

* Use pathstring in selectedLabel (if available)

* Mark structural nodes

* Retain selected value when dropdown open

* Add better placeholder values

* Simplify field selection
This commit is contained in:
Oliver
2026-06-30 22:59:04 +10:00
committed by GitHub
parent 7a81aa216f
commit 09f85aeae9
13 changed files with 688 additions and 62 deletions
+30 -3
View File
@@ -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,
+4 -1
View File
@@ -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):
@@ -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 (
<RelatedModelField
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
) {
// Redirect location fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
genericPlaceholder={t`Select location`}
model={ModelType.stocklocation}
navigate={navigate}
/>
);
} else if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.category_list)
) {
// Redirect category fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
genericPlaceholder={t`Select category`}
model={ModelType.partcategory}
navigate={navigate}
/>
);
} else {
return (
<RelatedModelField
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
}
case 'email':
case 'url':
case 'string':
@@ -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<FieldValues, any>;
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<number | null>(null);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const [expandedValues, setExpandedValues] = useState<string[]>([]);
const [nodes, setNodes] = useState<any[]>([]);
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<string, any> = {};
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<number, any> = {};
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<number, any> = {};
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<number, any>();
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 ? (
<Group gap={2} wrap='nowrap' style={{ paddingRight: 4 }}>
{!definition.required && selectValue && (
<ActionIcon
variant='transparent'
size='xs'
color='dimmed'
aria-label={t`Clear`}
onClick={(e: any) => {
cancelEvent(e);
onChange(null);
}}
>
<IconX size={12} />
</ActionIcon>
)}
<ModelHoverCard model={model} pk={selectedValue} navigate={navigate}>
<ActionIcon
variant='transparent'
size='xs'
component='a'
href={detailUrl || '#'}
target='_blank'
aria-label={t`View details`}
onClick={handleNavigate}
>
<IconLink size={12} />
</ActionIcon>
</ModelHoverCard>
</Group>
) : 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 (
<Input.Wrapper
label={definition.label}
description={definition.description}
required={definition.required}
error={definition.error ?? error?.message}
id={inputId}
>
<Group wrap='nowrap' gap={3}>
<Expand>
<TreeSelect
id={inputId}
data={treeData}
aria-label={`tree-field-${fieldName}`}
value={selectValue}
searchValue={inputSearchValue}
onChange={onChange}
onSearchChange={(val) => {
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 (
<Group
justify='space-between'
gap='xs'
wrap='nowrap'
style={{ flex: 1 }}
>
<Group gap={4} wrap='nowrap'>
{/* 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. */}
<span
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
style={{
display: 'inline-flex',
width: 16,
alignItems: 'center',
flexShrink: 0,
cursor: hasChildren ? 'pointer' : 'default'
}}
aria-label={expanded ? t`Collapse` : t`Expand`}
onClick={
hasChildren
? (event: any) => {
cancelEvent(event);
toggleExpanded(node.value);
}
: undefined
}
onKeyDown={
hasChildren
? (event: any) => {
if (event.key === 'Enter' || event.key === ' ') {
cancelEvent(event);
toggleExpanded(node.value);
}
}
: undefined
}
>
{hasChildren &&
(expanded ? (
<IconChevronDown size={14} />
) : (
<IconChevronRight size={14} />
))}
</span>
{raw?.icon && <ApiIcon name={raw.icon} size={14} />}
<Text
size='sm'
fw={selected ? 600 : undefined}
fs={raw?.structural ? 'italic' : undefined}
c={raw?.structural ? 'dimmed' : undefined}
>
{raw?.name ?? String(node.label)}
</Text>
</Group>
{raw?.description && (
<Text
size='xs'
c='dimmed'
ta='right'
truncate
flex={1}
maw='50%'
>
{raw.description}
</Text>
)}
</Group>
);
}}
/>
</Expand>
{addBarcodeField && (
<ScanButton modelType={model} onScanSuccess={onBarcodeScan} />
)}
</Group>
</Input.Wrapper>
);
}
@@ -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 (
<HoverCard
position='top-end'
withinPortal
openDelay={500}
closeDelay={100}
zIndex={99999}
>
<HoverCard.Target>
<span>{children}</span>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap='xs'>
<Group justify='space-between'>
<Text size='sm' fw='bold'>
{modelInfo.label()}
</Text>
<Text size='xs'>{`[${t`ID`}: ${pk}]`}</Text>
</Group>
{detailUrl && (
<Anchor
href={detailUrl}
target='_blank'
onClick={(event) => {
if (navigate) navigateToLink(detailUrl, navigate, event);
}}
>
<Group gap='xs' wrap='nowrap'>
<ActionIcon variant='transparent' size='xs'>
<IconLink />
</ActionIcon>
<Text size='sm'>{t`View details`}</Text>
</Group>
</Anchor>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
+1 -6
View File
@@ -128,12 +128,7 @@ export function RenderPartCategory(
<RenderInlineModel
{...props}
tooltip={instance.pathstring}
prefix={
<>
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
prefix={instance.icon && <ApiIcon name={instance.icon} />}
primary={category}
suffix={suffix}
url={
+1 -6
View File
@@ -48,12 +48,7 @@ export function RenderStockLocation(
<RenderInlineModel
{...props}
tooltip={instance.pathstring}
prefix={
<>
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
prefix={instance.icon && <ApiIcon name={instance.icon} />}
primary={location}
suffix={suffix}
url={
+13 -16
View File
@@ -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
+3 -5
View File
@@ -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();
});
@@ -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();
@@ -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');
+2 -1
View File
@@ -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();
+1 -1
View File
@@ -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