diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c441b199c..10a4265b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/docs/assets/images/concepts/ui_navigation_tree.png b/docs/docs/assets/images/concepts/ui_navigation_tree.png index 5d274452ab..e124cd51bf 100644 Binary files a/docs/docs/assets/images/concepts/ui_navigation_tree.png and b/docs/docs/assets/images/concepts/ui_navigation_tree.png differ diff --git a/docs/docs/concepts/user_interface.md b/docs/docs/concepts/user_interface.md index c9973c514c..80f9f231c2 100644 --- a/docs/docs/concepts/user_interface.md +++ b/docs/docs/concepts/user_interface.md @@ -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. diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 79b080bb1d..a85498596f 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -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() diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index fff7f52f77..14341d04fb 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 0df77ff826..1eb603dd7c 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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 diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index d0ce0d43fa..a9a87c7e80 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 448477782d..31ef0f915a 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -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.""" diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 4943dd6dfe..90b1770e62 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -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) diff --git a/src/frontend/src/components/nav/NavigationTree.tsx b/src/frontend/src/components/nav/NavigationTree.tsx index edca9924c5..ac214707d0 100644 --- a/src/frontend/src/components/nav/NavigationTree.tsx +++ b/src/frontend/src/components/nav/NavigationTree.tsx @@ -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([]); + // PKs of nodes whose children are currently being fetched + const [loadingNodes, setLoadingNodes] = useState>(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 = {}; + 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 = {}; + 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 = {}; 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: ( - - {query.data[ii].name} + + {raw.name} ), - 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 ( { - if (payload.hasChildren) { + if (isLoading || !hasChildren) return; + if (needsFetch) { + fetchChildren(payload.node.value); + } else { treeState.toggleExpanded(payload.node.value); } }} > - - + {(isLoading || hasChildren || payload.expanded) && ( + + {isLoading ? ( + + ) : hasChildren ? ( + payload.expanded ? ( + + ) : ( + + ) + ) : null} + + )} + - {payload.hasChildren ? ( - payload.expanded ? ( - - ) : ( - - ) - ) : null} - - follow(payload.node, event)} - aria-label={`nav-tree-item-${payload.node.value}`} - c='var(--mantine-color-text)' - > - {payload.node.label} - + + follow(payload.node, event)} + aria-label={`nav-tree-item-${payload.node.value}`} + c='var(--mantine-color-text)' + > + {payload.node.label} + + + + + + {nodeInfo.icon && } + + {nodeInfo.name} + + + {nodeInfo.description && ( + + {nodeInfo.description} + + )} + + + ); }, - [treeState] + [ + treeState, + childIdentifier, + follow, + loadingNodes, + fetchChildren, + debouncedSearch + ] ); return ( + setSearchValue(event.currentTarget.value)} + leftSection={} + rightSection={ + searchValue ? ( + setSearchValue('')} + aria-label={t`Clear search`} + > + + + ) : null + } + /> {query.isError ? ( }> {t`Error loading navigation tree.`} + ) : !query.isFetching && !query.isLoading && data.length === 0 ? ( + }> + {t`No results found`} + ) : ( - + )} diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 4c47958da5..9d0077e9c8 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -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); diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 5182a1c249..af83e85522 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1152,6 +1152,7 @@ export default function PartDetail() { {user.hasViewRole(UserRoles.part_category) && ( setTreeOpen(false)} selectedId={location?.pk} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 451b787afa..5d73866074 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -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} diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 0f568349f1..bcf8d64d72 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -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 }) => {