Updated playwright tests

This commit is contained in:
Oliver Walters 2026-06-24 14:13:03 +00:00
parent 4df12736dc
commit 2f16471128
8 changed files with 348 additions and 134 deletions

View File

@ -5,6 +5,7 @@ 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';
@ -131,6 +132,8 @@ export function ApiFormField({
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
model={ModelType.stocklocation}
navigate={navigate}
/>
);
case 'category':
@ -141,6 +144,8 @@ export function ApiFormField({
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
model={ModelType.partcategory}
navigate={navigate}
/>
);
case 'related field':
@ -155,6 +160,8 @@ export function ApiFormField({
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
model={ModelType.stocklocation}
navigate={navigate}
/>
);
} else if (
@ -167,6 +174,8 @@ export function ApiFormField({
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
model={ModelType.partcategory}
navigate={navigate}
/>
);
} else {

View File

@ -1,37 +1,68 @@
import { t } from '@lingui/core/macro';
import { Group, Text, type TreeNodeData, TreeSelect } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
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: when the user types, the API is queried with the search term
* and the backend returns matching nodes plus their ancestors for context.
* 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
childIdentifier,
model,
navigate
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
endpoint: ApiEndpoints;
childIdentifier: string;
model?: ModelType;
navigate?: NavigateFunction | null;
}>) {
const api = useApi();
const inputId = useId();
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
const {
field,
fieldState: { error }
@ -249,102 +280,208 @@ export function TreeField({
[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 (
<TreeSelect
data={treeData}
aria-label={`tree-field-${fieldName}`}
value={selectValue}
searchValue={inputSearchValue}
onChange={onChange}
onSearchChange={(val) => {
if (dropdownOpen.current) setSearchValue(val);
}}
searchable
filter={() => true}
clearable={!definition.required}
expandedValues={expandedValues}
onExpandedChange={setExpandedValues}
onDropdownOpen={() => {
dropdownOpen.current = true;
setIsDropdownOpen(true);
}}
onDropdownClose={() => {
dropdownOpen.current = false;
setIsDropdownOpen(false);
setSearchValue('');
}}
<Input.Wrapper
label={definition.label}
description={definition.description}
placeholder={definition.placeholder ?? t`Select...`}
required={definition.required}
disabled={definition.disabled}
error={definition.error ?? error?.message}
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
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);
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={definition.placeholder ?? 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
}
: undefined
}
onKeyDown={
hasChildren
? (event: any) => {
if (event.key === 'Enter' || event.key === ' ') {
cancelEvent(event);
toggleExpanded(node.value);
}
onKeyDown={
hasChildren
? (event: any) => {
if (event.key === 'Enter' || event.key === ' ') {
cancelEvent(event);
toggleExpanded(node.value);
}
}
: undefined
}
: 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}>
{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>
);
}}
/>
>
{hasChildren &&
(expanded ? (
<IconChevronDown size={14} />
) : (
<IconChevronRight size={14} />
))}
</span>
{raw?.icon && <ApiIcon name={raw.icon} size={14} />}
<Text size='sm' fw={selected ? 600 : 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>
);
}

View File

@ -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>
);
}

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

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();
});

View File

@ -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();

View File

@ -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');

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