mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-08 08:01: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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user