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:
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
- [#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)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 40 KiB |
@@ -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") }}
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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