mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-04 14:10:52 +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:
@@ -23,6 +23,7 @@ from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.config
|
||||
import InvenTree.filters
|
||||
import InvenTree.permissions
|
||||
import InvenTree.version
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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."""
|
||||
|
||||
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
|
||||
- Require "staff" access permissions for the machine restart API endpoint
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from InvenTree.api import (
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
ParameterListMixin,
|
||||
TreeMixin,
|
||||
meta_path,
|
||||
)
|
||||
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."""
|
||||
|
||||
model_class = PartCategory
|
||||
queryset = PartCategory.objects.all()
|
||||
serializer_class = part_serializers.CategoryTree
|
||||
|
||||
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']
|
||||
serializer_class = part_serializers.CategoryTreeSerializer
|
||||
filterset_class = CategoryTreeFilter
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return an annotated queryset for the CategoryTree endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = part_serializers.CategoryTree.annotate_queryset(queryset)
|
||||
queryset = part_serializers.CategoryTreeSerializer.annotate_queryset(queryset)
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
@@ -181,14 +181,25 @@ class CategorySerializer(
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
|
||||
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
class CategoryTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for PartCategory tree."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields."""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ from InvenTree.api import (
|
||||
BulkCreateMixin,
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
TreeMixin,
|
||||
meta_path,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER,
|
||||
InvenTreeDateFilter,
|
||||
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."""
|
||||
|
||||
model_class = StockLocation
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = StockSerializers.LocationTreeSerializer
|
||||
|
||||
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']}
|
||||
filterset_class = LocationTreeFilter
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for the StockLocationTree endpoint."""
|
||||
|
||||
@@ -1160,7 +1160,18 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Metaclass options."""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -5,28 +5,36 @@ import {
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
HoverCard,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
type RenderTreeNodePayload,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tree,
|
||||
type TreeNodeData,
|
||||
useTree
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconExclamationCircle,
|
||||
IconSitemap
|
||||
IconSearch,
|
||||
IconSitemap,
|
||||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
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 { StylishText } from '@lib/components/StylishText';
|
||||
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { resolveItem } from '@lib/functions/Conversion';
|
||||
import {
|
||||
eventModified,
|
||||
getDetailUrl,
|
||||
@@ -45,6 +53,7 @@ export default function NavigationTree({
|
||||
onClose,
|
||||
selectedId,
|
||||
modelType,
|
||||
childIdentifier,
|
||||
endpoint
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
@@ -52,26 +61,136 @@ export default function NavigationTree({
|
||||
onClose: () => void;
|
||||
selectedId?: number | null;
|
||||
modelType: ModelType;
|
||||
childIdentifier?: string;
|
||||
endpoint: ApiEndpoints;
|
||||
}>) {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
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({
|
||||
enabled: opened,
|
||||
queryKey: [modelType, opened],
|
||||
queryKey: [modelType, 'tree', opened, debouncedSearch, selectedId],
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(endpoint), {
|
||||
data: {
|
||||
ordering: 'level'
|
||||
params: {
|
||||
ordering: 'level',
|
||||
search: debouncedSearch || undefined,
|
||||
max_level: debouncedSearch ? undefined : 0,
|
||||
expand_to: debouncedSearch ? undefined : (selectedId ?? undefined)
|
||||
}
|
||||
})
|
||||
.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(
|
||||
(node: TreeNodeData, event?: any) => {
|
||||
const url = getDetailUrl(modelType, node.value);
|
||||
@@ -85,107 +204,156 @@ export default function NavigationTree({
|
||||
[modelType, navigate]
|
||||
);
|
||||
|
||||
// Map returned query to a "tree" structure
|
||||
const data: TreeNodeData[] = useMemo(() => {
|
||||
/*
|
||||
* Reconstruct the navigation tree from the provided data.
|
||||
* It is required (and assumed) that the data is first sorted by level.
|
||||
*/
|
||||
// In search mode use the query results directly; in browse mode use the accumulated lazy-load list
|
||||
const sourceNodes: any[] = useMemo(
|
||||
() => (debouncedSearch ? (query.data ?? []) : allNodes),
|
||||
[debouncedSearch, query.data, allNodes]
|
||||
);
|
||||
|
||||
// Map flat node list to a nested tree structure (parents must precede children)
|
||||
const data: TreeNodeData[] = useMemo(() => {
|
||||
const nodes: Record<number, any> = {};
|
||||
const tree: TreeNodeData[] = [];
|
||||
|
||||
if (!query || !query?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
if (!sourceNodes.length) 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 = {
|
||||
...query.data[ii],
|
||||
...raw,
|
||||
children: [],
|
||||
label: (
|
||||
<Group gap='xs'>
|
||||
<ApiIcon name={query.data[ii].icon} />
|
||||
{query.data[ii].name}
|
||||
<ApiIcon name={raw.icon} />
|
||||
<Text>{raw.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
value: query.data[ii].pk.toString(),
|
||||
selected: query.data[ii].pk === selectedId
|
||||
value: raw.pk.toString(),
|
||||
selected: raw.pk === selectedId
|
||||
};
|
||||
|
||||
const pk: number = node.pk;
|
||||
const parent: number | null = node.parent;
|
||||
|
||||
if (!parent) {
|
||||
// This is a top level node
|
||||
tree.push(node);
|
||||
} else {
|
||||
// This is *not* a top level node, so the parent *must* already exist
|
||||
nodes[parent]?.children.push(node);
|
||||
}
|
||||
|
||||
// Finally, add this 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;
|
||||
}, [selectedId, query.data]);
|
||||
}, [selectedId, sourceNodes]);
|
||||
|
||||
const renderNode = useCallback(
|
||||
(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 (
|
||||
<Group
|
||||
p={3}
|
||||
gap='xs'
|
||||
gap={5}
|
||||
justify='left'
|
||||
key={payload.node.value}
|
||||
wrap='nowrap'
|
||||
bg={isSelected ? 'var(--mantine-primary-color-light)' : undefined}
|
||||
style={{ borderRadius: 'var(--mantine-radius-sm)' }}
|
||||
onClick={() => {
|
||||
if (payload.hasChildren) {
|
||||
if (isLoading || !hasChildren) return;
|
||||
if (needsFetch) {
|
||||
fetchChildren(payload.node.value);
|
||||
} else {
|
||||
treeState.toggleExpanded(payload.node.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Space w={10 * (payload.level - 1)} />
|
||||
<ActionIcon
|
||||
size='sm'
|
||||
variant='transparent'
|
||||
aria-label={`nav-tree-toggle-${payload.node.value}}`}
|
||||
<Space w={25 * (payload.level - 1)} />
|
||||
{(isLoading || hasChildren || payload.expanded) && (
|
||||
<ActionIcon
|
||||
size='sm'
|
||||
variant='transparent'
|
||||
aria-label={`nav-tree-toggle-${payload.node.value}}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader size='xs' />
|
||||
) : hasChildren ? (
|
||||
payload.expanded ? (
|
||||
<IconChevronDown />
|
||||
) : (
|
||||
<IconChevronRight />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
)}
|
||||
<HoverCard
|
||||
width={260}
|
||||
shadow='md'
|
||||
withArrow
|
||||
closeDelay={100}
|
||||
openDelay={500}
|
||||
position='top-end'
|
||||
>
|
||||
{payload.hasChildren ? (
|
||||
payload.expanded ? (
|
||||
<IconChevronDown />
|
||||
) : (
|
||||
<IconChevronRight />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
<Anchor
|
||||
onClick={(event: any) => follow(payload.node, event)}
|
||||
aria-label={`nav-tree-item-${payload.node.value}`}
|
||||
c='var(--mantine-color-text)'
|
||||
>
|
||||
{payload.node.label}
|
||||
</Anchor>
|
||||
<HoverCard.Target>
|
||||
<Anchor
|
||||
onClick={(event: any) => follow(payload.node, event)}
|
||||
aria-label={`nav-tree-item-${payload.node.value}`}
|
||||
c='var(--mantine-color-text)'
|
||||
>
|
||||
{payload.node.label}
|
||||
</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>
|
||||
);
|
||||
},
|
||||
[treeState]
|
||||
[
|
||||
treeState,
|
||||
childIdentifier,
|
||||
follow,
|
||||
loadingNodes,
|
||||
fetchChildren,
|
||||
debouncedSearch
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
size='md'
|
||||
size='lg'
|
||||
position='left'
|
||||
onClose={onClose}
|
||||
withCloseButton={true}
|
||||
@@ -205,14 +373,43 @@ export default function NavigationTree({
|
||||
}
|
||||
>
|
||||
<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 />
|
||||
<LoadingOverlay visible={query.isFetching || query.isLoading} />
|
||||
{query.isError ? (
|
||||
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
|
||||
{t`Error loading navigation tree.`}
|
||||
</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>
|
||||
</Drawer>
|
||||
|
||||
@@ -377,6 +377,7 @@ export default function CategoryDetail() {
|
||||
modelType={ModelType.partcategory}
|
||||
title={t`Part Categories`}
|
||||
endpoint={ApiEndpoints.category_tree}
|
||||
childIdentifier='subcategories'
|
||||
opened={treeOpen}
|
||||
onClose={() => {
|
||||
setTreeOpen(false);
|
||||
|
||||
@@ -1152,6 +1152,7 @@ export default function PartDetail() {
|
||||
{user.hasViewRole(UserRoles.part_category) && (
|
||||
<NavigationTree
|
||||
title={t`Part Categories`}
|
||||
childIdentifier='subcategories'
|
||||
modelType={ModelType.partcategory}
|
||||
endpoint={ApiEndpoints.category_tree}
|
||||
opened={treeOpen}
|
||||
|
||||
@@ -514,6 +514,7 @@ export default function Stock() {
|
||||
title={t`Stock Locations`}
|
||||
modelType={ModelType.stocklocation}
|
||||
endpoint={ApiEndpoints.stock_location_tree}
|
||||
childIdentifier='sublocations'
|
||||
opened={treeOpen}
|
||||
onClose={() => setTreeOpen(false)}
|
||||
selectedId={location?.pk}
|
||||
|
||||
@@ -1066,6 +1066,7 @@ export default function StockDetail() {
|
||||
title={t`Stock Locations`}
|
||||
modelType={ModelType.stocklocation}
|
||||
endpoint={ApiEndpoints.stock_location_tree}
|
||||
childIdentifier='sublocations'
|
||||
opened={treeOpen}
|
||||
onClose={() => setTreeOpen(false)}
|
||||
selectedId={stockitem?.location}
|
||||
|
||||
@@ -70,6 +70,24 @@ test('Stock - Location Tree', async ({ browser }) => {
|
||||
await page.getByLabel('breadcrumb-1-factory').click();
|
||||
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user