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:
@@ -1770,7 +1770,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||||||
fields = ['items', 'notes', 'location']
|
fields = ['items', 'notes', 'location']
|
||||||
|
|
||||||
location = serializers.PrimaryKeyRelatedField(
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=StockLocation.objects.filter(structural=False),
|
queryset=StockLocation.objects.filter(),
|
||||||
many=False,
|
many=False,
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
@@ -1778,6 +1778,15 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||||||
help_text=_('Set stock location for counted items (optional)'),
|
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):
|
def save(self):
|
||||||
"""Count stock."""
|
"""Count stock."""
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
@@ -1874,7 +1883,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||||||
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
|
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
|
||||||
|
|
||||||
location = serializers.PrimaryKeyRelatedField(
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=StockLocation.objects.filter(structural=False),
|
queryset=StockLocation.objects.filter(),
|
||||||
many=False,
|
many=False,
|
||||||
required=True,
|
required=True,
|
||||||
allow_null=False,
|
allow_null=False,
|
||||||
@@ -1882,6 +1891,15 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||||||
help_text=_('Destination stock location'),
|
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):
|
def save(self):
|
||||||
"""Transfer stock."""
|
"""Transfer stock."""
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
@@ -1965,7 +1983,7 @@ class StockReturnSerializer(StockAdjustmentSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
location = serializers.PrimaryKeyRelatedField(
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=StockLocation.objects.filter(structural=False),
|
queryset=StockLocation.objects.filter(),
|
||||||
many=False,
|
many=False,
|
||||||
required=True,
|
required=True,
|
||||||
allow_null=False,
|
allow_null=False,
|
||||||
@@ -1973,6 +1991,15 @@ class StockReturnSerializer(StockAdjustmentSerializer):
|
|||||||
help_text=_('Destination stock location'),
|
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(
|
merge = serializers.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
@@ -2435,7 +2435,10 @@ class StocktakeTest(StockAPITestCase):
|
|||||||
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
|
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
|
||||||
expected_code=400,
|
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):
|
class StockTransferMergeTest(StockAPITestCase):
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useId } from '@mantine/hooks';
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { type Control, type FieldValues, useController } from 'react-hook-form';
|
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 type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
|
||||||
import { IconFileUpload } from '@tabler/icons-react';
|
import { IconFileUpload } from '@tabler/icons-react';
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
@@ -19,6 +22,7 @@ import { RelatedModelField } from './RelatedModelField';
|
|||||||
import { TableField } from './TableField';
|
import { TableField } from './TableField';
|
||||||
import TagsField from './TagsField';
|
import TagsField from './TagsField';
|
||||||
import TextField from './TextField';
|
import TextField from './TextField';
|
||||||
|
import { TreeField } from './TreeField';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an individual form field
|
* Render an individual form field
|
||||||
@@ -121,14 +125,48 @@ export function ApiFormField({
|
|||||||
const fieldInstance = useMemo(() => {
|
const fieldInstance = useMemo(() => {
|
||||||
switch (fieldDefinition.field_type) {
|
switch (fieldDefinition.field_type) {
|
||||||
case 'related field':
|
case 'related field':
|
||||||
return (
|
if (
|
||||||
<RelatedModelField
|
fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
|
||||||
definition={fieldDefinition}
|
) {
|
||||||
controller={controller}
|
// Redirect location fields to the appropriate tree field
|
||||||
fieldName={fieldName}
|
return (
|
||||||
navigate={navigate}
|
<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 'email':
|
||||||
case 'url':
|
case 'url':
|
||||||
case 'string':
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -128,12 +128,7 @@ export function RenderPartCategory(
|
|||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
{...props}
|
{...props}
|
||||||
tooltip={instance.pathstring}
|
tooltip={instance.pathstring}
|
||||||
prefix={
|
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||||
<>
|
|
||||||
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
|
|
||||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
primary={category}
|
primary={category}
|
||||||
suffix={suffix}
|
suffix={suffix}
|
||||||
url={
|
url={
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ export function RenderStockLocation(
|
|||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
{...props}
|
{...props}
|
||||||
tooltip={instance.pathstring}
|
tooltip={instance.pathstring}
|
||||||
prefix={
|
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||||
<>
|
|
||||||
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
|
|
||||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
primary={location}
|
primary={location}
|
||||||
suffix={suffix}
|
suffix={suffix}
|
||||||
url={
|
url={
|
||||||
|
|||||||
@@ -227,8 +227,10 @@ test('Build Order - Calendar', async ({ browser }) => {
|
|||||||
await page
|
await page
|
||||||
.getByRole('option', { name: 'Part Category', exact: true })
|
.getByRole('option', { name: 'Part Category', exact: true })
|
||||||
.click();
|
.click();
|
||||||
await page.getByLabel('related-field-filter-category').click();
|
await page.getByLabel('tree-field-filter-category').click();
|
||||||
await page.getByText('Part category, level 1').waitFor();
|
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
|
// Required because we downloaded a file
|
||||||
await page.context().close();
|
await page.context().close();
|
||||||
@@ -324,9 +326,9 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
|||||||
.getByRole('img', { name: 'field-batch_code-accept-placeholder' })
|
.getByRole('img', { name: 'field-batch_code-accept-placeholder' })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await page.getByLabel('related-field-location').click();
|
await page.getByLabel('tree-field-location').click();
|
||||||
await page.getByLabel('related-field-location').fill('Reel');
|
await page.getByLabel('tree-field-location').fill('Reel');
|
||||||
await page.getByText('- Electronics Lab/Reel Storage').click();
|
await page.getByText('Storage for component reels').click();
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
// Should be an error as the number of serial numbers doesn't match the quantity
|
// 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);
|
const row2 = await getRowFromCell(cell2);
|
||||||
await row2.getByLabel(/row-action-menu-/i).click();
|
await row2.getByLabel(/row-action-menu-/i).click();
|
||||||
await page.getByRole('menuitem', { name: 'Complete' }).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.getByText('Mechanical Lab').click();
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(100);
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
await page.getByText('Build outputs have been completed').waitFor();
|
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
|
// Next, adjust the "location" field - and check that the "quantity" field does not change
|
||||||
// Ref: https://github.com/inventree/InvenTree/pull/12081
|
// Ref: https://github.com/inventree/InvenTree/pull/12081
|
||||||
await page
|
await page.getByLabel('tree-field-location').fill('mechanical');
|
||||||
.getByRole('combobox', { name: 'related-field-location' })
|
await page.getByText('Mechanical Lab').click();
|
||||||
.fill('factory');
|
|
||||||
await page.getByTitle('Factory/Mechanical Lab').click();
|
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
// Check the 'quantity' value again - it should not have changed
|
// Check the 'quantity' value again - it should not have changed
|
||||||
@@ -539,11 +540,7 @@ test('Build Order - Auto Allocate Tracked', async ({ browser }) => {
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Wait for auto-filled form field
|
// Wait for auto-filled form field
|
||||||
await page
|
await page.getByText('Factory').waitFor();
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: /^Factory$/ })
|
|
||||||
.first()
|
|
||||||
.waitFor();
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
// Wait for one of the required parts to be allocated
|
// Wait for one of the required parts to be allocated
|
||||||
|
|||||||
@@ -973,7 +973,7 @@ test('Parts - Notes', async ({ browser }) => {
|
|||||||
await page.keyboard.press('Control+E');
|
await page.keyboard.press('Control+E');
|
||||||
await page.getByLabel('text-field-name', { exact: true }).waitFor();
|
await page.getByLabel('text-field-name', { exact: true }).waitFor();
|
||||||
await page.getByLabel('text-field-description', { 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();
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
// Enable notes editing
|
// Enable notes editing
|
||||||
@@ -1024,10 +1024,8 @@ test('Parts - Bulk Edit', async ({ browser }) => {
|
|||||||
|
|
||||||
await openDetailAction(page, 'part', 'set-category');
|
await openDetailAction(page, 'part', 'set-category');
|
||||||
|
|
||||||
await page.getByLabel('related-field-category').fill('rnitu');
|
await page.getByLabel('tree-field-category').fill('rnitu');
|
||||||
await page.waitForTimeout(250);
|
await page.getByText('Furniture and associated').click();
|
||||||
|
|
||||||
await page.getByRole('option', { name: '- Furniture/Chairs' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Update' }).click();
|
await page.getByRole('button', { name: 'Update' }).click();
|
||||||
await page.getByText('Items Updated').waitFor();
|
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();
|
await page.getByLabel('action-button-receive-items').click();
|
||||||
|
|
||||||
// Check for display of individual locations
|
// Check for display of individual locations
|
||||||
await page
|
await page.getByText('Parts Bins').first().waitFor();
|
||||||
.getByRole('cell', { name: /Choose Location/ })
|
await page.getByText('Room 101').first().waitFor();
|
||||||
.getByText('Parts Bins')
|
|
||||||
.waitFor();
|
|
||||||
await page
|
|
||||||
.getByRole('cell', { name: /Choose Location/ })
|
|
||||||
.getByText('Room 101')
|
|
||||||
.waitFor();
|
|
||||||
|
|
||||||
await page.getByText('Mechanical Lab').first().waitFor();
|
await page.getByText('Mechanical Lab').first().waitFor();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
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();
|
await page.getByRole('menuitem', { name: 'Receive line item' }).click();
|
||||||
|
|
||||||
// Select destination location
|
// Select destination location
|
||||||
await page.getByLabel('related-field-location').click();
|
await page.getByLabel('tree-field-location').fill('factory');
|
||||||
await page.getByRole('option', { name: 'Factory', exact: true }).click();
|
await page.getByText('Factory', { exact: true }).click();
|
||||||
|
|
||||||
// Receive only a *single* item
|
// Receive only a *single* item
|
||||||
await page.getByLabel('number-field-quantity').fill('1');
|
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' })
|
.getByRole('button', { name: 'action-button-receive-items' })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await page
|
await page.getByLabel('tree-field-location').fill('factory');
|
||||||
.getByRole('combobox', { name: 'related-field-location' })
|
await page.getByText('Factory', { exact: true }).click();
|
||||||
.fill('factory');
|
|
||||||
await page.getByText('Factory/Storage Room A').click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ test('Barcode Scanning - Forms', async ({ browser }) => {
|
|||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
|
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
|
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
|
||||||
.fill('INV-SL37');
|
.fill('INV-SL37');
|
||||||
|
|||||||
@@ -499,7 +499,8 @@ test('Stock - Tracking', async ({ browser }) => {
|
|||||||
|
|
||||||
// Navigate to the "stock tracking" tab
|
// Navigate to the "stock tracking" tab
|
||||||
await loadTab(page, 'Stock Tracking');
|
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('link', { name: 'Widget Assembly' }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ test('Forms - Stock Item Validation', async ({ browser }) => {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
|
|
||||||
// Set location
|
// Set location
|
||||||
await page.getByLabel('related-field-location').click();
|
await page.getByLabel('tree-field-location').fill('production');
|
||||||
await page.getByText('Electronics production facility').click();
|
await page.getByText('Electronics production facility').click();
|
||||||
|
|
||||||
// Create the stock item
|
// Create the stock item
|
||||||
|
|||||||
Reference in New Issue
Block a user