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/dashboard/widgets/NewsWidget.tsx b/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
index 5dab794dce..687fc5afc2 100644
--- a/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
+++ b/src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
@@ -130,9 +130,13 @@ export default function NewsWidget() {