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

[UI] Tree improvements (#12204)

* Hide expand icon for items without children

* Add searching to CategoryTree API

* Add "level" filter

* Automatically include parent tree when searching

* Include tree_id field

* Add search input to NavigationTree

* Add more API filters

* Load child nodes iteratively

* Fix dynamic loading of nodes

* Highlight selected item

* Include pathstring

* Fix insertion order

* Auto-expand to the selected ID

* Add "no results" message

* Refactor into generic components

* Expand to multi level

* Use async node loading functionality

* Add hovercard

* Implement same functionality for StockLocationTree API endpoint

* Adjust spacing

* Add connecting lines

* Add playwright test

* Bump API version

* Add CHANGELOG entry

* Update docs

* Update screenshot
This commit is contained in:
Oliver
2026-06-19 15:33:12 +10:00
committed by GitHub
parent 6285a11a65
commit 01fb74af25
15 changed files with 411 additions and 86 deletions
+1
View File
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12204](https://github.com/inventree/InvenTree/pull/12204) adds new filtering options to PartCategoryTree and StockLocationTree API endpoints, allowing tree data to be fetched dynamically
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model - [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns. - [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.
- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API) - [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+12
View File
@@ -91,6 +91,18 @@ Click on the navigation tree icon to expand the tree and view the available navi
{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }} {{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }}
#### Searching
The navigation tree includes a search bar at the top of the panel. Typing into the search bar filters the tree to show only entries that match the search query. When a search is active, all matching results are expanded and displayed in a flat list. Clearing the search field returns the tree to its normal browsing mode.
#### Highlight Selected Entry
The currently selected entry in the navigation tree is highlighted with a distinct background color, making it easy to identify the active page or section within the hierarchy.
#### Auto-Expand to Selected Entry
When the navigation tree is opened, it automatically expands to reveal the currently selected entry. All ancestor nodes in the hierarchy are expanded so the active entry is immediately visible, without requiring manual navigation through the tree.
## Dashboard ## Dashboard
The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions. The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions.
+41
View File
@@ -23,6 +23,7 @@ from rest_framework.serializers import ValidationError
from rest_framework.views import APIView from rest_framework.views import APIView
import InvenTree.config import InvenTree.config
import InvenTree.filters
import InvenTree.permissions import InvenTree.permissions
import InvenTree.version import InvenTree.version
from common.settings import get_global_setting from common.settings import get_global_setting
@@ -963,3 +964,43 @@ def meta_path(model, lookup_field: str = 'pk', lookup_field_ref: str = 'pk'):
lookup_field_ref=lookup_field_ref, lookup_field_ref=lookup_field_ref,
), ),
) )
class TreeMixin:
"""A mixin class for supporting tree-structured data in the API."""
# Any API view which inherits from this mixin must define a 'model_class' attribute
model_class = None
filter_backends = InvenTree.filters.SEARCH_ORDER_FILTER
search_fields = ['name', 'description']
ordering_fields = ['level', 'name', 'subcategories']
ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}
ordering = ['level']
def filter_queryset(self, queryset):
"""Filter the queryset, and provide extra support for tree-structured data."""
queryset = super().filter_queryset(queryset)
# If a search term is provided, include all ancestors of matched items in the results
if self.request.query_params.get('search', '').strip():
ancestors = self.model_class.objects.get_queryset_ancestors(
queryset, include_self=True
)
queryset = queryset | ancestors
# If a specific ID is provided to "expand_to", include all ancestors and siblings
if expand_to := self.request.query_params.get('expand_to'):
try:
target = self.model_class.objects.get(pk=int(expand_to))
target_ancestors = target.get_ancestors(include_self=True)
queryset = queryset | target_ancestors
# We also want to include the "sibling" nodes of the expanded item
siblings = target.get_siblings(include_self=True)
queryset = queryset | siblings
except (self.model_class.DoesNotExist, ValueError):
pass
return queryset.distinct()
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 510 INVENTREE_API_VERSION = 511
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v511 -> 2026-06-19 : https://github.com/inventree/InvenTree/pull/12204
- Adds new filtering options to PartCategoryTree and StockLocationTree API endpoints
v510 -> 2026-06-18 : https://github.com/inventree/InvenTree/pull/12197 v510 -> 2026-06-18 : https://github.com/inventree/InvenTree/pull/12197
- Require "staff" access permissions for the machine restart API endpoint - Require "staff" access permissions for the machine restart API endpoint
+26 -12
View File
@@ -21,6 +21,7 @@ from InvenTree.api import (
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
ParameterListMixin, ParameterListMixin,
TreeMixin,
meta_path, meta_path,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
@@ -284,25 +285,38 @@ class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDest
) )
class CategoryTree(ListAPI): class CategoryTreeFilter(FilterSet):
"""Custom filterset class for the CategoryTree endpoint."""
class Meta:
"""Metaclass options for this filterset."""
model = PartCategory
fields = ['parent', 'tree_id', 'level']
max_level = rest_filters.NumberFilter(
label=_('Max Level'),
method='filter_max_level',
help_text=_('Limit the depth of the category tree'),
)
def filter_max_level(self, queryset, name, value):
"""Filter by the maximum depth of the category tree."""
return queryset.filter(level__lte=value)
class CategoryTree(TreeMixin, ListAPI):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree.""" """API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
model_class = PartCategory
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree serializer_class = part_serializers.CategoryTreeSerializer
filterset_class = CategoryTreeFilter
filter_backends = ORDER_FILTER
ordering_fields = ['level', 'name', 'subcategories']
ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryTree endpoint.""" """Return an annotated queryset for the CategoryTree endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.CategoryTree.annotate_queryset(queryset) queryset = part_serializers.CategoryTreeSerializer.annotate_queryset(queryset)
return queryset return queryset
+13 -2
View File
@@ -181,14 +181,25 @@ class CategorySerializer(
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): class CategoryTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory tree.""" """Serializer for PartCategory tree."""
class Meta: class Meta:
"""Metaclass defining serializer fields.""" """Metaclass defining serializer fields."""
model = PartCategory model = PartCategory
fields = ['pk', 'name', 'parent', 'icon', 'structural', 'subcategories'] fields = [
'pk',
'name',
'description',
'pathstring',
'parent',
'tree_id',
'level',
'icon',
'structural',
'subcategories',
]
subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True) subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True)
+24 -11
View File
@@ -33,11 +33,11 @@ from InvenTree.api import (
BulkCreateMixin, BulkCreateMixin,
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
TreeMixin,
meta_path, meta_path,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER,
SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER,
InvenTreeDateFilter, InvenTreeDateFilter,
NumberOrNullFilter, NumberOrNullFilter,
@@ -455,20 +455,33 @@ class StockLocationDetail(
) )
class StockLocationTree(ListAPI): class LocationTreeFilter(FilterSet):
"""Custom filterset class for the StockLocationTree endpoint."""
class Meta:
"""Metaclass options for this filterset."""
model = StockLocation
fields = ['parent', 'tree_id', 'level']
max_level = rest_filters.NumberFilter(
label=_('Max Level'),
method='filter_max_level',
help_text=_('Limit the depth of the category tree'),
)
def filter_max_level(self, queryset, name, value):
"""Filter by the maximum depth of the category tree."""
return queryset.filter(level__lte=value)
class StockLocationTree(TreeMixin, ListAPI):
"""API endpoint for accessing a list of StockLocation objects, ready for rendering as a tree.""" """API endpoint for accessing a list of StockLocation objects, ready for rendering as a tree."""
model_class = StockLocation
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = StockSerializers.LocationTreeSerializer serializer_class = StockSerializers.LocationTreeSerializer
filterset_class = LocationTreeFilter
filter_backends = ORDER_FILTER
ordering_fields = ['level', 'name', 'sublocations']
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationTree endpoint.""" """Return annotated queryset for the StockLocationTree endpoint."""
+12 -1
View File
@@ -1160,7 +1160,18 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Metaclass options.""" """Metaclass options."""
model = StockLocation model = StockLocation
fields = ['pk', 'name', 'parent', 'icon', 'structural', 'sublocations'] fields = [
'pk',
'name',
'description',
'pathstring',
'parent',
'tree_id',
'level',
'icon',
'structural',
'sublocations',
]
sublocations = serializers.IntegerField(label=_('Sublocations'), read_only=True) sublocations = serializers.IntegerField(label=_('Sublocations'), read_only=True)
@@ -5,28 +5,36 @@ import {
Divider, Divider,
Drawer, Drawer,
Group, Group,
HoverCard,
Loader,
LoadingOverlay, LoadingOverlay,
type RenderTreeNodePayload, type RenderTreeNodePayload,
Space, Space,
Stack, Stack,
Text,
TextInput,
Tree, Tree,
type TreeNodeData, type TreeNodeData,
useTree useTree
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { import {
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconExclamationCircle, IconExclamationCircle,
IconSitemap IconSearch,
IconSitemap,
IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText'; import { StylishText } from '@lib/components/StylishText';
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType'; import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { resolveItem } from '@lib/functions/Conversion';
import { import {
eventModified, eventModified,
getDetailUrl, getDetailUrl,
@@ -45,6 +53,7 @@ export default function NavigationTree({
onClose, onClose,
selectedId, selectedId,
modelType, modelType,
childIdentifier,
endpoint endpoint
}: Readonly<{ }: Readonly<{
title: string; title: string;
@@ -52,26 +61,136 @@ export default function NavigationTree({
onClose: () => void; onClose: () => void;
selectedId?: number | null; selectedId?: number | null;
modelType: ModelType; modelType: ModelType;
childIdentifier?: string;
endpoint: ApiEndpoints; endpoint: ApiEndpoints;
}>) { }>) {
const api = useApi(); const api = useApi();
const navigate = useNavigate(); const navigate = useNavigate();
const treeState = useTree(); const treeState = useTree();
// Data query to fetch the tree data from server const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
// Accumulated flat node list for browse (lazy-load) mode
const [allNodes, setAllNodes] = useState<any[]>([]);
// PKs of nodes whose children are currently being fetched
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
// Reset everything when the drawer opens or closes
useEffect(() => {
setSearchValue('');
setAllNodes([]);
setLoadingNodes(new Set());
}, [opened]);
// Data query — browse mode loads root nodes only; search mode loads all matches + ancestors
const query = useQuery({ const query = useQuery({
enabled: opened, enabled: opened,
queryKey: [modelType, opened], queryKey: [modelType, 'tree', opened, debouncedSearch, selectedId],
queryFn: async () => queryFn: async () =>
api api
.get(apiUrl(endpoint), { .get(apiUrl(endpoint), {
data: { params: {
ordering: 'level' ordering: 'level',
search: debouncedSearch || undefined,
max_level: debouncedSearch ? undefined : 0,
expand_to: debouncedSearch ? undefined : (selectedId ?? undefined)
} }
}) })
.then((response) => response.data ?? []) .then((response) => response.data ?? [])
}); });
// When the browse-mode query settles, reset the node list and expand ancestors of the selection
useEffect(() => {
if (!debouncedSearch && query.data && !query.isFetching) {
setAllNodes(query.data);
setLoadingNodes(new Set());
if (selectedId) {
const nodeMap: Record<number, any> = {};
for (const n of query.data) nodeMap[n.pk] = n;
// Collect every ancestor pk, then apply in one setExpandedState call to
// avoid closure/batching issues that arise from calling expand() in a loop.
const toExpand: Record<string, boolean> = {};
let current = nodeMap[selectedId];
while (current?.parent) {
toExpand[current.parent.toString()] = true;
current = nodeMap[current.parent];
}
if (Object.keys(toExpand).length) {
treeState.setExpandedState({
...treeState.expandedState,
...toExpand
});
}
}
}
}, [debouncedSearch, query.data, query.isFetching, selectedId]);
// Collapse all nodes when the search term changes (switching modes).
// Intentionally omits query.data so it does NOT fire when browse results arrive —
// that would undo the ancestor expansion done above.
useEffect(() => {
treeState.collapseAllNodes();
}, [debouncedSearch]);
// Expand all nodes once search results have fully arrived
useEffect(() => {
if (debouncedSearch && !query.isFetching && query.data?.length) {
treeState.expandAllNodes();
}
}, [debouncedSearch, query.data, query.isFetching]);
// Fetch direct children of a node (browse mode only).
// Zeros out the childIdentifier count on success with no results so the node
// is treated as a leaf and won't be re-fetched on subsequent clicks.
const fetchChildren = useCallback(
async (nodeValue: string) => {
const pk = Number.parseInt(nodeValue);
if (loadingNodes.has(pk)) return;
const nodeInfo = allNodes.find((n) => n.pk === pk);
if (!nodeInfo) return;
setLoadingNodes((prev) => new Set([...prev, pk]));
try {
const response = await api.get(apiUrl(endpoint), {
params: {
ordering: 'level',
parent: pk,
max_level: nodeInfo.level + 1
}
});
const children: any[] = response.data ?? [];
setAllNodes((prev) => {
if (children.length === 0 && childIdentifier) {
// No children returned — zero out the count so this node is treated
// as a leaf and won't trigger another fetch on the next click.
return prev.map((n) =>
n.pk === pk ? { ...n, [childIdentifier]: 0 } : n
);
}
const existing = new Set(prev.map((n) => n.pk));
return [...prev, ...children.filter((n) => !existing.has(n.pk))];
});
if (children.length > 0) {
treeState.expand(nodeValue);
}
} finally {
setLoadingNodes((prev) => {
const next = new Set(prev);
next.delete(pk);
return next;
});
}
},
[loadingNodes, allNodes, api, endpoint, childIdentifier]
);
const follow = useCallback( const follow = useCallback(
(node: TreeNodeData, event?: any) => { (node: TreeNodeData, event?: any) => {
const url = getDetailUrl(modelType, node.value); const url = getDetailUrl(modelType, node.value);
@@ -85,83 +204,98 @@ export default function NavigationTree({
[modelType, navigate] [modelType, navigate]
); );
// Map returned query to a "tree" structure // In search mode use the query results directly; in browse mode use the accumulated lazy-load list
const data: TreeNodeData[] = useMemo(() => { const sourceNodes: any[] = useMemo(
/* () => (debouncedSearch ? (query.data ?? []) : allNodes),
* Reconstruct the navigation tree from the provided data. [debouncedSearch, query.data, allNodes]
* It is required (and assumed) that the data is first sorted by level. );
*/
// Map flat node list to a nested tree structure (parents must precede children)
const data: TreeNodeData[] = useMemo(() => {
const nodes: Record<number, any> = {}; const nodes: Record<number, any> = {};
const tree: TreeNodeData[] = []; const tree: TreeNodeData[] = [];
if (!query || !query?.data?.length) { if (!sourceNodes.length) return [];
return [];
}
for (let ii = 0; ii < query.data.length; ii++) { // Sort by level so parents are always inserted before their children,
// regardless of the order the API returns items (e.g. after ancestor union in search mode).
const sorted = [...sourceNodes].sort((a, b) => a.level - b.level);
for (const raw of sorted) {
const node = { const node = {
...query.data[ii], ...raw,
children: [], children: [],
label: ( label: (
<Group gap='xs'> <Group gap='xs'>
<ApiIcon name={query.data[ii].icon} /> <ApiIcon name={raw.icon} />
{query.data[ii].name} <Text>{raw.name}</Text>
</Group> </Group>
), ),
value: query.data[ii].pk.toString(), value: raw.pk.toString(),
selected: query.data[ii].pk === selectedId selected: raw.pk === selectedId
}; };
const pk: number = node.pk; const pk: number = node.pk;
const parent: number | null = node.parent; const parent: number | null = node.parent;
if (!parent) { if (!parent) {
// This is a top level node
tree.push(node); tree.push(node);
} else { } else {
// This is *not* a top level node, so the parent *must* already exist
nodes[parent]?.children.push(node); nodes[parent]?.children.push(node);
} }
// Finally, add this node
nodes[pk] = node; nodes[pk] = node;
if (pk === selectedId) {
// Expand all parents
let parent = nodes[node.parent];
while (parent) {
parent.expanded = true;
parent = nodes[parent.parent];
}
}
} }
return tree; return tree;
}, [selectedId, query.data]); }, [selectedId, sourceNodes]);
const renderNode = useCallback( const renderNode = useCallback(
(payload: RenderTreeNodePayload) => { (payload: RenderTreeNodePayload) => {
const nodeInfo = payload.node as any;
const pk = Number.parseInt(payload.node.value);
const isLoading = loadingNodes.has(pk);
// A node has children if they are already in the tree, or if the server-side
// count (childIdentifier) says so and they haven't been loaded yet.
const childrenLoaded = nodeInfo.children.length > 0;
const needsFetch =
!isLoading &&
!debouncedSearch &&
!childrenLoaded &&
!!(childIdentifier && resolveItem(payload.node, childIdentifier));
const hasChildren = childrenLoaded || needsFetch;
const isSelected = nodeInfo.selected === true;
return ( return (
<Group <Group
p={3} p={3}
gap='xs' gap={5}
justify='left' justify='left'
key={payload.node.value} key={payload.node.value}
wrap='nowrap' wrap='nowrap'
bg={isSelected ? 'var(--mantine-primary-color-light)' : undefined}
style={{ borderRadius: 'var(--mantine-radius-sm)' }}
onClick={() => { onClick={() => {
if (payload.hasChildren) { if (isLoading || !hasChildren) return;
if (needsFetch) {
fetchChildren(payload.node.value);
} else {
treeState.toggleExpanded(payload.node.value); treeState.toggleExpanded(payload.node.value);
} }
}} }}
> >
<Space w={10 * (payload.level - 1)} /> <Space w={25 * (payload.level - 1)} />
{(isLoading || hasChildren || payload.expanded) && (
<ActionIcon <ActionIcon
size='sm' size='sm'
variant='transparent' variant='transparent'
aria-label={`nav-tree-toggle-${payload.node.value}}`} aria-label={`nav-tree-toggle-${payload.node.value}}`}
> >
{payload.hasChildren ? ( {isLoading ? (
<Loader size='xs' />
) : hasChildren ? (
payload.expanded ? ( payload.expanded ? (
<IconChevronDown /> <IconChevronDown />
) : ( ) : (
@@ -169,6 +303,16 @@ export default function NavigationTree({
) )
) : null} ) : null}
</ActionIcon> </ActionIcon>
)}
<HoverCard
width={260}
shadow='md'
withArrow
closeDelay={100}
openDelay={500}
position='top-end'
>
<HoverCard.Target>
<Anchor <Anchor
onClick={(event: any) => follow(payload.node, event)} onClick={(event: any) => follow(payload.node, event)}
aria-label={`nav-tree-item-${payload.node.value}`} aria-label={`nav-tree-item-${payload.node.value}`}
@@ -176,16 +320,40 @@ export default function NavigationTree({
> >
{payload.node.label} {payload.node.label}
</Anchor> </Anchor>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap={4}>
<Group gap='xs' wrap='nowrap'>
{nodeInfo.icon && <ApiIcon name={nodeInfo.icon} />}
<Text fw={600} size='sm'>
{nodeInfo.name}
</Text>
</Group>
{nodeInfo.description && (
<Text size='sm' c='dimmed'>
{nodeInfo.description}
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group> </Group>
); );
}, },
[treeState] [
treeState,
childIdentifier,
follow,
loadingNodes,
fetchChildren,
debouncedSearch
]
); );
return ( return (
<Drawer <Drawer
opened={opened} opened={opened}
size='md' size='lg'
position='left' position='left'
onClose={onClose} onClose={onClose}
withCloseButton={true} withCloseButton={true}
@@ -205,14 +373,43 @@ export default function NavigationTree({
} }
> >
<Stack gap='xs'> <Stack gap='xs'>
<TextInput
aria-label='nav-tree-search'
placeholder={t`Search...`}
value={searchValue}
onChange={(event) => setSearchValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSection={
searchValue ? (
<ActionIcon
size='sm'
variant='transparent'
onClick={() => setSearchValue('')}
aria-label={t`Clear search`}
>
<IconX size={14} />
</ActionIcon>
) : null
}
/>
<Divider /> <Divider />
<LoadingOverlay visible={query.isFetching || query.isLoading} /> <LoadingOverlay visible={query.isFetching || query.isLoading} />
{query.isError ? ( {query.isError ? (
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}> <Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
{t`Error loading navigation tree.`} {t`Error loading navigation tree.`}
</Alert> </Alert>
) : !query.isFetching && !query.isLoading && data.length === 0 ? (
<Alert color='blue' icon={<IconSearch />}>
{t`No results found`}
</Alert>
) : ( ) : (
<Tree data={data} tree={treeState} renderNode={renderNode} /> <Tree
data={data}
tree={treeState}
renderNode={renderNode}
withLines
levelOffset={25}
/>
)} )}
</Stack> </Stack>
</Drawer> </Drawer>
@@ -377,6 +377,7 @@ export default function CategoryDetail() {
modelType={ModelType.partcategory} modelType={ModelType.partcategory}
title={t`Part Categories`} title={t`Part Categories`}
endpoint={ApiEndpoints.category_tree} endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
opened={treeOpen} opened={treeOpen}
onClose={() => { onClose={() => {
setTreeOpen(false); setTreeOpen(false);
@@ -1152,6 +1152,7 @@ export default function PartDetail() {
{user.hasViewRole(UserRoles.part_category) && ( {user.hasViewRole(UserRoles.part_category) && (
<NavigationTree <NavigationTree
title={t`Part Categories`} title={t`Part Categories`}
childIdentifier='subcategories'
modelType={ModelType.partcategory} modelType={ModelType.partcategory}
endpoint={ApiEndpoints.category_tree} endpoint={ApiEndpoints.category_tree}
opened={treeOpen} opened={treeOpen}
@@ -514,6 +514,7 @@ export default function Stock() {
title={t`Stock Locations`} title={t`Stock Locations`}
modelType={ModelType.stocklocation} modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree} endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
opened={treeOpen} opened={treeOpen}
onClose={() => setTreeOpen(false)} onClose={() => setTreeOpen(false)}
selectedId={location?.pk} selectedId={location?.pk}
@@ -1066,6 +1066,7 @@ export default function StockDetail() {
title={t`Stock Locations`} title={t`Stock Locations`}
modelType={ModelType.stocklocation} modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree} endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
opened={treeOpen} opened={treeOpen}
onClose={() => setTreeOpen(false)} onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location} selectedId={stockitem?.location}
@@ -70,6 +70,24 @@ test('Stock - Location Tree', async ({ browser }) => {
await page.getByLabel('breadcrumb-1-factory').click(); await page.getByLabel('breadcrumb-1-factory').click();
await page.getByRole('cell', { name: 'Factory' }).first().waitFor(); await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
// Load the tree again - from a deeply nested location
// We expect it to auto-expand to the current location
await navigate(page, 'stock/location/17/stock-items');
await page.getByLabel('nav-breadcrumb-action').click();
for (let ii = 0; ii <= 5; ii++) {
await page
.locator('div')
.filter({ hasText: `Location ${ii}` })
.first()
.waitFor();
}
// Let's search for a particular location
await page.getByRole('textbox', { name: 'nav-tree-search' }).fill('room');
await page.getByText('Storage Room A').first().waitFor();
await page.getByText('Storage Room B').first().waitFor();
}); });
test('Stock - Location Delete', async ({ browser }) => { test('Stock - Location Delete', async ({ browser }) => {