From 75a08a1e06c3f406f57a8a4200cfaaf0960ce88b Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Jun 2026 19:38:22 +1000 Subject: [PATCH 1/8] [feature] tags support (#12077) * Add Tag API endpoints * Enable filtering by model type * Remove old tags filters against Part endpoint * Add generic tags filter for filtering against tagged items * Add API unit tests for the tags API endpoints * Create generic mixin class for adding tags support * Update existing tagged models * Add tags to more model types * Enable new tags API filtering for multiple models * Add support for tag filtering in part table * Update transfer table filters * Add tags filter to more places * Allow multiple values to be selected as filters * Add a new 'tags' type form field * Display tags on part page * tags support for orders * Add support for SalesOrderShipment * build order * Company support * SupplierPart and ManufacturerPart * support StockItem * Enable tag filtering for attachments * Make tagslist readonly * docs * Mark props as read only * Update API version * Update CHANGELOG * force tags to be case insensitive * Add playwright test for build order tags * more playwright testing * Fix docs link --- CHANGELOG.md | 1 + docs/docs/concepts/tags.md | 82 +++++++++ docs/mkdocs.yml | 1 + .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/InvenTree/models.py | 20 +++ .../InvenTree/InvenTree/serializers.py | 4 - src/backend/InvenTree/InvenTree/settings.py | 3 + src/backend/InvenTree/build/api.py | 3 + .../build/migrations/0059_build_tags.py | 29 +++ src/backend/InvenTree/build/models.py | 1 + src/backend/InvenTree/build/serializers.py | 5 + src/backend/InvenTree/common/api.py | 53 ++++++ src/backend/InvenTree/common/filters.py | 31 ++++ src/backend/InvenTree/common/models.py | 9 +- src/backend/InvenTree/common/serializers.py | 29 ++- src/backend/InvenTree/common/test_api.py | 168 ++++++++++++++++++ src/backend/InvenTree/company/api.py | 25 ++- .../company/migrations/0080_company_tags.py | 29 +++ src/backend/InvenTree/company/models.py | 8 +- src/backend/InvenTree/company/serializers.py | 12 +- src/backend/InvenTree/order/api.py | 5 + ...turnorder_tags_salesorder_tags_and_more.py | 73 ++++++++ src/backend/InvenTree/order/models.py | 2 + src/backend/InvenTree/order/serializers.py | 13 ++ src/backend/InvenTree/part/api.py | 5 +- src/backend/InvenTree/part/models.py | 4 +- src/backend/InvenTree/stock/api.py | 4 + src/backend/InvenTree/stock/models.py | 7 +- src/backend/InvenTree/stock/serializers.py | 6 +- src/frontend/lib/components/TagsList.tsx | 27 +++ src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/lib/enums/ModelInformation.tsx | 6 + src/frontend/lib/enums/ModelType.tsx | 3 +- src/frontend/lib/index.ts | 1 + src/frontend/lib/types/Filters.tsx | 3 + src/frontend/lib/types/Forms.tsx | 3 +- .../components/forms/fields/ApiFormField.tsx | 5 + .../components/forms/fields/BooleanField.tsx | 4 +- .../src/components/forms/fields/TagsField.tsx | 81 +++++++++ .../src/components/render/Generic.tsx | 6 + .../src/components/render/Instance.tsx | 6 +- src/frontend/src/forms/BuildForms.tsx | 2 + src/frontend/src/forms/CommonFields.tsx | 19 ++ src/frontend/src/forms/CompanyForms.tsx | 4 + src/frontend/src/forms/PartForms.tsx | 2 + src/frontend/src/forms/PurchaseOrderForms.tsx | 2 + src/frontend/src/forms/ReturnOrderForms.tsx | 2 + src/frontend/src/forms/SalesOrderForms.tsx | 3 + src/frontend/src/forms/StockForms.tsx | 2 + src/frontend/src/forms/TransferOrderForms.tsx | 2 + src/frontend/src/pages/build/BuildDetail.tsx | 30 ++-- .../src/pages/company/CompanyDetail.tsx | 43 +++-- .../pages/company/ManufacturerPartDetail.tsx | 36 ++-- .../src/pages/company/SupplierPartDetail.tsx | 36 ++-- src/frontend/src/pages/part/PartDetail.tsx | 6 +- .../pages/purchasing/PurchaseOrderDetail.tsx | 30 ++-- .../src/pages/sales/ReturnOrderDetail.tsx | 30 ++-- .../src/pages/sales/SalesOrderDetail.tsx | 30 ++-- .../pages/sales/SalesOrderShipmentDetail.tsx | 42 +++-- .../src/pages/stock/LocationDetail.tsx | 24 ++- src/frontend/src/pages/stock/StockDetail.tsx | 35 ++-- .../src/pages/stock/TransferOrderDetail.tsx | 24 ++- src/frontend/src/tables/Filter.tsx | 23 +++ .../src/tables/FilterSelectDrawer.tsx | 89 +++++++++- .../src/tables/build/BuildOrderFilters.tsx | 4 +- .../src/tables/company/CompanyTable.tsx | 4 +- .../src/tables/part/PartTableFilters.tsx | 7 +- .../purchasing/ManufacturerPartTable.tsx | 4 +- .../purchasing/PurchaseOrderFilters.tsx | 4 +- .../tables/purchasing/SupplierPartTable.tsx | 4 +- .../src/tables/sales/ReturnOrderFilters.tsx | 4 +- .../src/tables/sales/SalesOrderFilters.tsx | 4 +- .../tables/sales/SalesOrderShipmentTable.tsx | 7 +- .../src/tables/stock/StockItemTable.tsx | 4 +- .../src/tables/stock/TransferOrderFilters.tsx | 79 ++++++++ .../src/tables/stock/TransferOrderTable.tsx | 63 +------ src/frontend/tests/pages/pui_build.spec.ts | 45 +++++ src/frontend/tests/pages/pui_sales.spec.ts | 23 +++ 78 files changed, 1294 insertions(+), 263 deletions(-) create mode 100644 docs/docs/concepts/tags.md create mode 100644 src/backend/InvenTree/build/migrations/0059_build_tags.py create mode 100644 src/backend/InvenTree/company/migrations/0080_company_tags.py create mode 100644 src/backend/InvenTree/order/migrations/0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more.py create mode 100644 src/frontend/lib/components/TagsList.tsx create mode 100644 src/frontend/src/components/forms/fields/TagsField.tsx create mode 100644 src/frontend/src/forms/CommonFields.tsx create mode 100644 src/frontend/src/tables/stock/TransferOrderFilters.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f5d6d53b..0e18a582a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags. - [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation. - [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created. - [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules. diff --git a/docs/docs/concepts/tags.md b/docs/docs/concepts/tags.md new file mode 100644 index 0000000000..87454431c0 --- /dev/null +++ b/docs/docs/concepts/tags.md @@ -0,0 +1,82 @@ +--- +title: Tags +--- + +## Tags + +*Tags* are short, arbitrary labels that can be attached to InvenTree objects to group or classify them in flexible ways that don't require changes to the underlying data model. Unlike [parameters](./parameters.md), tags carry no typed value — they are simply names. A tag can be applied to objects of any supported model type, and tags are shared across the entire InvenTree instance. + +!!! note "Shared Tag Namespace" + Tags are global: a tag named `prototype` applied to a Part and the same tag applied to a Build Order refer to the same underlying tag record. Renaming or deleting a tag affects every object to which it is attached. + +### Supported Models + +Tags can be attached to the following InvenTree objects: + +- [Parts](../part/index.md) +- [Supplier Parts](../purchasing/supplier.md#supplier-parts) +- [Manufacturer Parts](../purchasing/manufacturer.md#manufacturer-parts) +- [Companies](./company.md) +- [Stock Items](../stock/index.md#stock-item) +- [Stock Locations](../stock/index.md#stock-location) +- [Build Orders](../manufacturing/build.md) +- [Purchase Orders](../purchasing/purchase_order.md) +- [Sales Orders](../sales/sales_order.md) +- [Return Orders](../sales/return_order.md) +- [Sales Order Shipments](../sales/sales_order.md#shipments) + +## Managing Tags + +### Adding and Removing Tags + +Any object that supports tags will expose a *Tags* field in its detail and edit forms. Tags are entered as a comma-separated list of names and can be freely added or removed at any time. Tag names are case-insensitive — `Prototype`, `prototype`, and `PROTOTYPE` all refer to the same tag. + +### Tag Names + +Tag names must be unique within the InvenTree instance (case-insensitively). If you type a name that already exists under a different capitalisation, the existing tag is assigned rather than a new one created. Tag names may contain spaces, but leading and trailing whitespace is stripped automatically. + +## Filtering by Tags + +Tables that support tags can be filtered by one or more tag names. When multiple tags are specified, only objects that carry **all** of the specified tags are returned (AND logic). + +For example, filtering a Parts table by the tags `approved` and `prototype` returns only parts tagged with both. + +## API Access + +### Tag Endpoints + +The tag list is available at `/api/tag/`. Individual tags can be retrieved, updated, or deleted at `/api/tag//`. + +The `model_type` query parameter narrows the tag list to tags currently applied to a specific model type: + +``` +GET /api/tag/?model_type=part +``` + +### Tags on Model Endpoints + +For models that support tags, the `tags` field is returned in the detail endpoint response as a list of tag name strings: + +```json +{ + "pk": 42, + "name": "Widget", + "tags": ["approved", "prototype"] +} +``` + +Tags can be updated via a `PATCH` or `POST` request by supplying a JSON-encoded list of tag name strings. The full list of tags replaces the previous set — omitting a tag removes it: + +```json +{ + "tags": ["approved", "production"] +} +``` + +Tags can also be used as a filter parameter on list endpoints. Supply a comma-separated list of tag names to the `tags` query parameter: + +``` +GET /api/part/?tags=approved,prototype +``` + +This returns only parts tagged with **both** `approved` and `prototype`. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a2827bb068..16f051309f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -102,6 +102,7 @@ nav: - Project Codes: concepts/project_codes.md - Attachments: concepts/attachments.md - Parameters: concepts/parameters.md + - Tags: concepts/tags.md - Barcodes: - Barcode Support: barcodes/index.md - Internal Barcodes: barcodes/internal.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6997504b6f..a80da8408d 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 499 +INVENTREE_API_VERSION = 500 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v500 -> 2026-06-03 : https://github.com/inventree/InvenTree/pull/12077 + - Adds "tags" fields to multiple new model types + - Adds /api/tag/ endpoint for fetching tags + - Enable filtering various model types by tags + v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057 - Fixes search field issues on the BarcodeScanHistory API endpoint diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 2155b2296a..1c5a4c786b 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -24,6 +24,7 @@ from error_report.models import Error from mptt.exceptions import InvalidMove from mptt.models import MPTTModel, TreeForeignKey from stdimage.models import StdImageField +from taggit.managers import TaggableManager import common.settings import InvenTree.exceptions @@ -1250,6 +1251,25 @@ class InvenTreeNotesMixin(models.Model): ) +class InvenTreeTagsMixin(models.Model): + """A mixin class for adding tag functionality to a model class. + + The following fields are added to any model which implements this mixin: + + - tags : A text field for storing comma-separated tags + """ + + class Meta: + """Metaclass options for this mixin. + + Note: abstract must be true, as this is only a mixin, not a separate table + """ + + abstract = True + + tags = TaggableManager(blank=True) + + class InvenTreeBarcodeMixin(models.Model): """A mixin class for adding barcode functionality to a model class. diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index fc9073dd0b..ba75e875d8 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -699,10 +699,6 @@ class InvenTreeTaggitSerializer(TaggitSerializer): return self._save_tags(tag_object, to_be_tagged) -class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer): - """Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer.""" - - class InvenTreeAttachmentSerializerField(serializers.FileField): """Override the DRF native FileField serializer, to remove the leading server path. diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 3b06820c98..68a3c81bf4 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1169,3 +1169,6 @@ if 'dbbackup' not in STORAGES: if _media: MEDIA_URL = _media PRESIGNED_URL_EXPIRATION = 600 + +# Taggit settings +TAGGIT_CASE_INSENSITIVE = True diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 3a1455306b..571f2b1e7c 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -16,6 +16,7 @@ from rest_framework.response import Response import build.models as build_models import build.serializers +import common.filters import common.models import common.serializers import part.models as part_models @@ -307,6 +308,8 @@ class BuildFilter(FilterSet): return queryset + tags = common.filters.TagsFilter() + class BuildMixin: """Mixin class for Build API endpoints.""" diff --git a/src/backend/InvenTree/build/migrations/0059_build_tags.py b/src/backend/InvenTree/build/migrations/0059_build_tags.py new file mode 100644 index 0000000000..329722a5dd --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0059_build_tags.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.14 on 2026-06-03 10:07 + +import taggit.managers +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("build", "0058_buildline_consumed"), + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + ] + + operations = [ + migrations.AddField( + model_name="build", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index dfcf26e0b4..09eb77e3a5 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -79,6 +79,7 @@ class Build( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeTagsMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 0db1cc0b1b..d9d3550f4b 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -36,6 +36,7 @@ from InvenTree.serializers import ( FilterableSerializerMixin, InvenTreeDecimalField, InvenTreeModelSerializer, + InvenTreeTaggitSerializer, NotesFieldMixin, OptionalField, ) @@ -57,6 +58,7 @@ class BuildSerializer( CustomStatusSerializerMixin, FilterableSerializerMixin, NotesFieldMixin, + InvenTreeTaggitSerializer, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer, @@ -103,6 +105,7 @@ class BuildSerializer( 'parameters', 'priority', 'level', + 'tags', ] read_only_fields = [ 'completed', @@ -127,6 +130,8 @@ class BuildSerializer( parameters = common.filters.enable_parameters_filter() + tags = common.filters.enable_tags_filter() + part_name = serializers.CharField( source='part.name', read_only=True, label=_('Part Name') ) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 5726b32f91..9785623e61 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -36,6 +36,7 @@ from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from sql_util.utils import SubqueryCount +from taggit.models import Tag import common.filters import common.models @@ -504,6 +505,48 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI): permission_classes = [IsStaffOrReadOnlyScope] +class TagFilter(FilterSet): + """Custom filters for the TagList API endpoint.""" + + class Meta: + """Metaclass options for the filterset.""" + + model = Tag + fields = [] + + model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') + + def filter_model_type(self, queryset, name, value): + """Filter to tags which have been applied to the given model type.""" + ct = common.filters.determine_content_type(value) + + if ct is None: + raise ValidationError({'model_type': f'Invalid model type: {value}'}) + + return queryset.filter(taggit_taggeditem_items__content_type=ct).distinct() + + +class TagMixin: + """Mixin class for Tag views.""" + + serializer_class = common.serializers.TagSerializer + queryset = Tag.objects.all() + permission_classes = [IsStaffOrReadOnlyScope] + + +class TagList(TagMixin, ListCreateAPI): + """List view for all tags.""" + + filterset_class = TagFilter + filter_backends = SEARCH_ORDER_FILTER + ordering_fields = ['name'] + search_fields = ['name'] + + +class TagDetail(TagMixin, RetrieveUpdateDestroyAPI): + """Detail view for a particular tag.""" + + class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet): """List view for custom units.""" @@ -745,6 +788,8 @@ class AttachmentFilter(FilterSet): return queryset.exclude(attachment=None).exclude(attachment='') return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct() + tags = common.filters.TagsFilter() + class AttachmentMixin: """Mixin class for Attachment views.""" @@ -1554,6 +1599,14 @@ common_api_urls = [ path('', ProjectCodeList.as_view(), name='api-project-code-list'), ]), ), + # Tags (via django-taggit) + path( + 'tag/', + include([ + path('/', TagDetail.as_view(), name='api-tag-detail'), + path('', TagList.as_view(), name='api-tag-list'), + ]), + ), # Flags path( 'flags/', diff --git a/src/backend/InvenTree/common/filters.py b/src/backend/InvenTree/common/filters.py index c3cff3677d..e8a2d0741e 100644 --- a/src/backend/InvenTree/common/filters.py +++ b/src/backend/InvenTree/common/filters.py @@ -19,6 +19,7 @@ from django.db.models import ( from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ +import django_filters.rest_framework.filters as rest_filters from rest_framework import serializers from taggit.serializers import TagListSerializerField @@ -93,6 +94,36 @@ def filter_content_type( return queryset.filter(q) +class TagsFilter(rest_filters.CharFilter): + """Filter which accepts a comma-separated list of tag names and returns only objects that have ALL of the specified tags. + + Example usage in a FilterSet: + tags = TagsFilter(label=_('Tags')) + + Example query: + ?tags=apple,banana → returns only items tagged with both 'apple' AND 'banana' + """ + + def __init__(self, *args, **kwargs): + """Initialize the filter.""" + if 'label' not in kwargs: + kwargs['label'] = _('Tags') + + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + """Filter queryset to items matching all provided tag names.""" + if not value: + return qs + + tag_names = [t.strip() for t in value.split(',') if t.strip()] + + for tag in tag_names: + qs = qs.filter(tags__name__iexact=tag) + + return qs.distinct() + + """A list of valid operators for filtering part parameters.""" PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 23d5352e9b..baeb700288 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -50,7 +50,6 @@ from djmoney.contrib.exchange.models import convert_money from opentelemetry import trace from PIL import Image from rest_framework.exceptions import PermissionDenied -from taggit.managers import TaggableManager import common.validators import InvenTree.conversion @@ -1923,7 +1922,11 @@ def rename_attachment(instance, filename: str): ) -class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): +class Attachment( + InvenTree.models.MetadataMixin, + InvenTree.models.InvenTreeTagsMixin, + InvenTree.models.InvenTreeModel, +): """Class which represents an uploaded file attachment. An attachment can be either an uploaded file, or an external URL. @@ -2156,8 +2159,6 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel default=0, verbose_name=_('File size'), help_text=_('File size in bytes') ) - tags = TaggableManager(blank=True) - @property def basename(self): """Base name/path for attachment.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index c8e2f13920..522ef62e36 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -12,6 +12,7 @@ from error_report.models import Error from flags.state import flag_state from rest_framework import serializers from rest_framework.exceptions import PermissionDenied +from taggit.models import Tag import common.filters import common.models as common_models @@ -28,6 +29,7 @@ from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, + InvenTreeTaggitSerializer, OptionalField, ) from plugin import registry as plugin_registry @@ -422,6 +424,29 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria ) +@register_importer() +class TagSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): + """Serializer for the Tag model.""" + + class Meta: + """Meta options for TagSerializer.""" + + model = Tag + fields = ['pk', 'name', 'slug'] + read_only_fields = ['pk', 'slug'] + + def validate(self, data): + """Slugify the received name to generate the slug.""" + from django.utils.text import slugify + + name = data.get('name', None) + + if name is not None: + data['slug'] = slugify(name) + + return data + + @register_importer() class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): """Serializer for the custom state model.""" @@ -720,7 +745,9 @@ class FailedTaskSerializer(InvenTreeModelSerializer): result = serializers.CharField() -class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): +class AttachmentSerializer( + FilterableSerializerMixin, InvenTreeTaggitSerializer, InvenTreeModelSerializer +): """Serializer class for the Attachment model.""" class Meta: diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index d4875c9014..6cfcdad4db 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -8,6 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from PIL import Image +from taggit.models import Tag import common.models from InvenTree.unit_test import InvenTreeAPITestCase @@ -999,3 +1000,170 @@ class AttachmentThumbnailAPITests(InvenTreeAPITestCase): set_global_setting( 'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None ) + + +class TagAPITests(InvenTreeAPITestCase): + """Tests for the Tag API endpoints and tag-based filtering.""" + + roles = 'all' + + LIST_URL = 'api-tag-list' + DETAIL_URL = 'api-tag-detail' + + def setUp(self): + """Create a small set of tagged objects for filter testing.""" + super().setUp() + + from part.models import Part + + self.part_a = Part.objects.create( + name='Tagged Part A', description='Part with apple and banana tags' + ) + self.part_b = Part.objects.create( + name='Tagged Part B', description='Part with apple tag only' + ) + self.part_c = Part.objects.create( + name='Untagged Part C', description='Part with no tags' + ) + + self.part_a.tags.add('apple', 'banana') + self.part_b.tags.add('apple') + + # ------------------------------------------------------------------ + # Tag list / CRUD + # ------------------------------------------------------------------ + + def test_tag_list(self): + """Tag list endpoint should return all existing tags.""" + url = reverse(self.LIST_URL) + response = self.get(url) + + names = {t['name'] for t in response.data} + self.assertIn('apple', names) + self.assertIn('banana', names) + + def test_tag_create(self): + """Staff users should be able to create tags via POST.""" + url = reverse(self.LIST_URL) + n = Tag.objects.count() + + response = self.post(url, {'name': 'cherry'}, expected_code=201) + self.assertEqual(response.data['name'], 'cherry') + self.assertEqual(Tag.objects.count(), n + 1) + + def test_tag_create_non_staff(self): + """Non-staff users must not be able to create tags.""" + self.user.is_staff = False + self.user.save() + + url = reverse(self.LIST_URL) + self.post(url, {'name': 'forbidden'}, expected_code=403) + + def test_tag_edit(self): + """Staff users should be able to rename a tag via PATCH.""" + tag = Tag.objects.get(name='banana') + url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk}) + + response = self.patch(url, {'name': 'blueberry'}, expected_code=200) + self.assertEqual(response.data['name'], 'blueberry') + + tag.refresh_from_db() + self.assertEqual(tag.name, 'blueberry') + + def test_tag_delete(self): + """Staff users should be able to delete a tag.""" + tag = Tag.objects.get(name='banana') + url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk}) + + self.delete(url, expected_code=204) + self.assertFalse(Tag.objects.filter(name='banana').exists()) + + def test_tag_search(self): + """The list endpoint should support free-text search.""" + url = reverse(self.LIST_URL) + + response = self.get(url, data={'search': 'app'}) + names = [t['name'] for t in response.data] + self.assertIn('apple', names) + self.assertNotIn('banana', names) + + # ------------------------------------------------------------------ + # Filter by model type + # ------------------------------------------------------------------ + + def test_tag_filter_model_type(self): + """Tags applied to a given model type should be returned when filtering by model_type.""" + url = reverse(self.LIST_URL) + + # Filter for tags applied to Part objects + response = self.get(url, data={'model_type': 'part.part'}) + names = {t['name'] for t in response.data} + + self.assertIn('apple', names) + self.assertIn('banana', names) + + def test_tag_filter_model_type_unrelated(self): + """Filtering by a model type that has no tagged objects should return an empty list.""" + url = reverse(self.LIST_URL) + + # StockItem has no tagged objects in this test + response = self.get(url, data={'model_type': 'stock.stockitem'}) + self.assertEqual(len(response.data), 0) + + def test_tag_filter_model_type_invalid(self): + """An unrecognised model_type value should return a 400 error.""" + url = reverse(self.LIST_URL) + self.get(url, data={'model_type': 'notanapp.notamodel'}, expected_code=400) + + # ------------------------------------------------------------------ + # Filter Part list by tags + # ------------------------------------------------------------------ + + def test_part_filter_single_tag(self): + """Filtering parts by a single tag should return only parts with that tag.""" + url = reverse('api-part-list') + + response = self.get(url, data={'tags': 'apple'}) + pks = {p['pk'] for p in response.data} + + self.assertIn(self.part_a.pk, pks) + self.assertIn(self.part_b.pk, pks) + self.assertNotIn(self.part_c.pk, pks) + + def test_part_filter_multiple_tags_and(self): + """Filtering by comma-separated tags should return only parts that have ALL tags.""" + url = reverse('api-part-list') + + response = self.get(url, data={'tags': 'apple,banana'}) + pks = {p['pk'] for p in response.data} + + self.assertIn(self.part_a.pk, pks) + self.assertNotIn(self.part_b.pk, pks) # only has 'apple' + self.assertNotIn(self.part_c.pk, pks) # no tags at all + + def test_part_filter_tag_case_insensitive(self): + """Tag filtering should be case-insensitive.""" + url = reverse('api-part-list') + + response = self.get(url, data={'tags': 'APPLE'}) + pks = {p['pk'] for p in response.data} + + self.assertIn(self.part_a.pk, pks) + self.assertIn(self.part_b.pk, pks) + + def test_part_filter_nonexistent_tag(self): + """Filtering by a tag that no part has should return an empty result set.""" + url = reverse('api-part-list') + + response = self.get(url, data={'tags': 'doesnotexist'}) + self.assertEqual(len(response.data), 0) + + def test_part_filter_tag_whitespace(self): + """Whitespace around comma-separated tag names should be ignored.""" + url = reverse('api-part-list') + + response = self.get(url, data={'tags': ' apple , banana '}) + pks = {p['pk'] for p in response.data} + + self.assertIn(self.part_a.pk, pks) + self.assertNotIn(self.part_b.pk, pks) diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 3702c0c71e..5c1dc26206 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework.filterset import FilterSet +import common.filters import part.models from data_exporter.mixins import DataExportViewMixin from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path @@ -37,6 +38,18 @@ from .serializers import ( ) +class CompanyFilter(FilterSet): + """Custom API filters for the CompanyList endpoint.""" + + class Meta: + """Metaclass options.""" + + model = Company + fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name', 'active'] + + tags = common.filters.TagsFilter() + + class CompanyMixin(OutputOptionsMixin): """Mixin class for Company API endpoints.""" @@ -62,13 +75,7 @@ class CompanyList(CompanyMixin, ParameterListMixin, DataExportViewMixin, ListCre filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'is_customer', - 'is_manufacturer', - 'is_supplier', - 'name', - 'active', - ] + filterset_class = CompanyFilter search_fields = ['name', 'description', 'website', 'tax_id'] @@ -145,6 +152,8 @@ class ManufacturerPartFilter(FilterSet): field_name='manufacturer__active', label=_('Manufacturer is Active') ) + tags = common.filters.TagsFilter(label=_('Tags')) + class ManufacturerOutputOptions(OutputConfiguration): """Available output options for the ManufacturerPart endpoints.""" @@ -299,6 +308,8 @@ class SupplierPartFilter(FilterSet): else: return queryset.exclude(in_stock__gt=0) + tags = common.filters.TagsFilter(label=_('Tags')) + class SupplierPartOutputOptions(OutputConfiguration): """Available output options for the SupplierPart endpoints.""" diff --git a/src/backend/InvenTree/company/migrations/0080_company_tags.py b/src/backend/InvenTree/company/migrations/0080_company_tags.py new file mode 100644 index 0000000000..42f3da00a0 --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0080_company_tags.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.14 on 2026-06-03 10:07 + +import taggit.managers +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0079_auto_20260212_1054"), + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + ] + + operations = [ + migrations.AddField( + model_name="company", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 431f7ae11b..888bafe92e 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import pgettext_lazy as __ from moneyed import CURRENCIES -from taggit.managers import TaggableManager import common.currency import common.models @@ -80,6 +79,7 @@ class Company( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeTagsMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, InvenTree.models.InvenTreeMetadataModel, @@ -487,6 +487,7 @@ class ManufacturerPart( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeTagsMixin, InvenTree.models.InvenTreeMetadataModel, ): """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. @@ -559,8 +560,6 @@ class ManufacturerPart( help_text=_('Manufacturer part description'), ) - tags = TaggableManager(blank=True) - @classmethod def create(cls, part, manufacturer, mpn, description, link=None): """Check if ManufacturerPart instance does not already exist then create it.""" @@ -603,6 +602,7 @@ class SupplierPart( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeTagsMixin, InvenTree.models.InvenTreeNotesMixin, common.models.MetaMixin, InvenTree.models.InvenTreeModel, @@ -640,8 +640,6 @@ class SupplierPart( # This model was moved from the 'Part' app db_table = 'part_supplierpart' - tags = TaggableManager(blank=True) - @staticmethod def get_api_url(): """Return the API URL associated with the SupplierPart model.""" diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 0ff71a39bc..1aa191e966 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -20,7 +20,7 @@ from InvenTree.serializers import ( InvenTreeImageSerializerField, InvenTreeModelSerializer, InvenTreeMoneySerializer, - InvenTreeTagModelSerializer, + InvenTreeTaggitSerializer, NotesFieldMixin, OptionalField, ) @@ -108,6 +108,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer): class CompanySerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, + InvenTreeTaggitSerializer, NotesFieldMixin, InvenTreeModelSerializer, ): @@ -143,6 +144,7 @@ class CompanySerializer( 'primary_address', 'tax_id', 'parameters', + 'tags', ] @staticmethod @@ -185,6 +187,8 @@ class CompanySerializer( help_text=_('Default currency used for this supplier'), required=True ) + tags = common.filters.enable_tags_filter() + parameters = common.filters.enable_parameters_filter() @@ -207,8 +211,9 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize class ManufacturerPartSerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, - InvenTreeTagModelSerializer, + InvenTreeTaggitSerializer, NotesFieldMixin, + InvenTreeModelSerializer, ): """Serializer for ManufacturerPart object.""" @@ -308,8 +313,9 @@ class SupplierPriceBreakBriefSerializer( class SupplierPartSerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, - InvenTreeTagModelSerializer, + InvenTreeTaggitSerializer, NotesFieldMixin, + InvenTreeModelSerializer, ): """Serializer for SupplierPart object.""" diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 73269e5f30..dbe2127a33 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -22,6 +22,7 @@ from rest_framework.exceptions import NotFound from rest_framework.response import Response import build.models +import common.filters import common.models import common.serializers import common.settings @@ -277,6 +278,8 @@ class OrderFilter(FilterSet): return queryset.filter(q1 | q2 | q3 | q4).distinct() + tags = common.filters.TagsFilter() + class LineItemFilter(FilterSet): """Base class for custom API filters for order line item list(s).""" @@ -1448,6 +1451,8 @@ class SalesOrderShipmentFilter(FilterSet): return queryset.filter(q1 | q2).distinct() + tags = common.filters.TagsFilter() + class SalesOrderShipmentMixin: """Mixin class for SalesOrderShipment endpoints.""" diff --git a/src/backend/InvenTree/order/migrations/0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more.py b/src/backend/InvenTree/order/migrations/0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more.py new file mode 100644 index 0000000000..cd027f68e7 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.2.14 on 2026-06-03 10:07 + +import taggit.managers +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("order", "0119_transferorderlineitem_line_int"), + ( + "taggit", + "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx", + ), + ] + + operations = [ + migrations.AddField( + model_name="purchaseorder", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + migrations.AddField( + model_name="returnorder", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + migrations.AddField( + model_name="salesorder", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + migrations.AddField( + model_name="salesordershipment", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + migrations.AddField( + model_name="transferorder", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index ba587a9719..1109e65a02 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -300,6 +300,7 @@ class Order( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeTagsMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin, @@ -2465,6 +2466,7 @@ class SalesOrderShipment( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeTagsMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 91b0c9d2d2..108a6d51c1 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -37,6 +37,7 @@ from InvenTree.serializers import ( InvenTreeDecimalField, InvenTreeModelSerializer, InvenTreeMoneySerializer, + InvenTreeTaggitSerializer, NotesFieldMixin, OptionalField, ) @@ -103,6 +104,7 @@ class DuplicateOrderSerializer(serializers.Serializer): class AbstractOrderSerializer( CustomStatusSerializerMixin, DataImportExportSerializerMixin, + InvenTreeTaggitSerializer, FilterableSerializerMixin, serializers.Serializer, ): @@ -172,6 +174,8 @@ class AbstractOrderSerializer( parameters = common.filters.enable_parameters_filter() + tags = common.filters.enable_tags_filter() + # Boolean field indicating if this order is overdue (Note: must be annotated) overdue = serializers.BooleanField(read_only=True, allow_null=True) @@ -236,6 +240,7 @@ class AbstractOrderSerializer( 'project_code_label', 'responsible_detail', 'parameters', + 'tags', *extra_fields, ] @@ -1065,6 +1070,7 @@ class SalesOrderSerializer( TotalPriceMixin, InvenTreeCustomStatusSerializerMixin, AbstractOrderSerializer, + InvenTreeTaggitSerializer, InvenTreeModelSerializer, ): """Serializer for the SalesOrder model class.""" @@ -1084,6 +1090,7 @@ class SalesOrderSerializer( 'completed_shipments_count', 'allocated_lines', 'updated_at', + 'tags', ]) read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at'] extra_kwargs = {'order_currency': {'required': False}} @@ -1165,6 +1172,8 @@ class SalesOrderSerializer( read_only=True, allow_null=True, label=_('Allocated Lines') ) + tags = common.filters.enable_tags_filter() + class SalesOrderIssueSerializer(OrderAdjustSerializer): """Serializer for issuing a SalesOrder.""" @@ -1363,6 +1372,7 @@ class SalesOrderLineItemSerializer( class SalesOrderShipmentSerializer( DataImportExportSerializerMixin, FilterableSerializerMixin, + InvenTreeTaggitSerializer, NotesFieldMixin, InvenTreeModelSerializer, ): @@ -1392,6 +1402,7 @@ class SalesOrderShipmentSerializer( 'customer_detail', 'order_detail', 'shipment_address_detail', + 'tags', ] @staticmethod @@ -1461,6 +1472,8 @@ class SalesOrderShipmentSerializer( parameters = common.filters.enable_parameters_filter() + tags = common.filters.enable_tags_filter() + class SalesOrderAllocationSerializer( FilterableSerializerMixin, InvenTreeModelSerializer diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 0eac19e221..0df77ff826 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -12,6 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_field from rest_framework import serializers from rest_framework.response import Response +import common.filters import common.serializers import part.tasks as part_tasks from data_exporter.mixins import DataExportViewMixin @@ -910,9 +911,7 @@ class PartFilter(FilterSet): virtual = rest_filters.BooleanFilter() - tags_name = rest_filters.CharFilter(field_name='tags__name', lookup_expr='iexact') - - tags_slug = rest_filters.CharFilter(field_name='tags__slug', lookup_expr='iexact') + tags = common.filters.TagsFilter() # Created date filters created_before = InvenTreeDateFilter( diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 55e26db261..9ffe3d42e9 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -32,7 +32,6 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money from mptt.managers import TreeManager from mptt.models import TreeForeignKey -from taggit.managers import TaggableManager import common.currency import common.models @@ -465,6 +464,7 @@ class Part( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeTagsMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, @@ -520,8 +520,6 @@ class Part( objects = TreeManager() - tags = TaggableManager(blank=True) - class Meta: """Metaclass defines extra model properties.""" diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index d1eff6b112..662c6877c1 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -373,6 +373,8 @@ class StockLocationFilter(FilterSet): return queryset + tags = common.filters.TagsFilter(label=_('Tags')) + class StockLocationMixin(SerializerContextMixin): """Mixin class for StockLocation API endpoints.""" @@ -1041,6 +1043,8 @@ class StockFilter(FilterSet): children = loc_obj.getUniqueChildren() return queryset.filter(location__in=children) + tags = common.filters.TagsFilter(label=_('Tags')) + class StockApiMixin(SerializerContextMixin): """Mixin class for StockItem API endpoints.""" diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index ae8ec477e1..c7c4f653fe 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -23,7 +23,6 @@ import structlog from djmoney.contrib.exchange.models import convert_money from mptt.managers import TreeManager from mptt.models import TreeForeignKey -from taggit.managers import TaggableManager import build.models import common.models @@ -126,6 +125,7 @@ class StockLocation( InvenTree.models.PluginValidationMixin, InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeTagsMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.PathStringMixin, InvenTree.models.MetadataMixin, @@ -149,8 +149,6 @@ class StockLocation( verbose_name = _('Stock Location') verbose_name_plural = _('Stock Locations') - tags = TaggableManager(blank=True) - def delete(self, *args, **kwargs): """Custom model deletion routine, which updates any child locations or items. @@ -426,6 +424,7 @@ class StockItem( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeTagsMixin, StatusCodeMixin, report.mixins.InvenTreeReportMixin, common.models.MetaMixin, @@ -619,8 +618,6 @@ class StockItem( 'test_templates': self.part.getTestTemplateMap(), } - tags = TaggableManager(blank=True) - # A Query filter which will be reused in multiple places to determine if a StockItem is actually "in stock" # See also: StockItem.in_stock() method IN_STOCK_FILTER = Q( diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index cefe76ab4f..4943dd6dfe 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -313,7 +313,8 @@ class StockItemSerializer( InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, - InvenTree.serializers.InvenTreeTagModelSerializer, + InvenTree.serializers.InvenTreeTaggitSerializer, + InvenTree.serializers.InvenTreeModelSerializer, ): """Serializer for a StockItem. @@ -1194,7 +1195,8 @@ class LocationDeleteSerializer(serializers.Serializer): class LocationSerializer( InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, - InvenTree.serializers.InvenTreeTagModelSerializer, + InvenTree.serializers.InvenTreeTaggitSerializer, + InvenTree.serializers.InvenTreeModelSerializer, ): """Detailed information about a stock location.""" diff --git a/src/frontend/lib/components/TagsList.tsx b/src/frontend/lib/components/TagsList.tsx new file mode 100644 index 0000000000..f1cbd1cdd5 --- /dev/null +++ b/src/frontend/lib/components/TagsList.tsx @@ -0,0 +1,27 @@ +import { ActionIcon, Badge, Group, Paper } from '@mantine/core'; +import { IconTag } from '@tabler/icons-react'; + +export default function TagsList({ + tags +}: Readonly<{ + tags: string[]; +}>) { + if (!tags || tags.length === 0) { + return null; + } + + return ( + + + + + + {tags.map((tag: string) => ( + + {tag} + + ))} + + + ); +} diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index daee58fef9..972b45e60c 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -259,6 +259,7 @@ export enum ApiEndpoints { config_list = 'admin/config/', parameter_list = 'parameter/', parameter_template_list = 'parameter/template/', + tag_list = 'tag/', // Internal system things system_internal_trace_end = 'system-internal/observability/end' diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index 4eb6b4934d..9c14e42da9 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -319,5 +319,11 @@ export const ModelInformationDict: ModelDict = { url_overview: '/settings/admin/errors', url_detail: '/settings/admin/errors/:pk/', icon: 'exclamation' + }, + tag: { + label: () => t`Tag`, + label_multiple: () => t`Tags`, + api_endpoint: ApiEndpoints.tag_list, + icon: 'tag' } }; diff --git a/src/frontend/lib/enums/ModelType.tsx b/src/frontend/lib/enums/ModelType.tsx index 80c436b1b1..76642756e2 100644 --- a/src/frontend/lib/enums/ModelType.tsx +++ b/src/frontend/lib/enums/ModelType.tsx @@ -38,7 +38,8 @@ export enum ModelType { contenttype = 'contenttype', selectionlist = 'selectionlist', selectionentry = 'selectionentry', - error = 'error' + error = 'error', + tag = 'tag' } export enum PluginPanelKey { diff --git a/src/frontend/lib/index.ts b/src/frontend/lib/index.ts index 3642e341ba..0dfb4f4742 100644 --- a/src/frontend/lib/index.ts +++ b/src/frontend/lib/index.ts @@ -110,6 +110,7 @@ export { ProgressBar } from './components/ProgressBar'; export { PassFailButton, YesNoButton } from './components/YesNoButton'; export { SearchInput } from './components/SearchInput'; export { TableColumnSelect } from './components/TableColumnSelect'; +export { default as TagsList } from './components/TagsList'; export { default as InvenTreeTable } from './components/InvenTreeTable'; export { RowViewAction, diff --git a/src/frontend/lib/types/Filters.tsx b/src/frontend/lib/types/Filters.tsx index 53d5a563ca..afc07c79d9 100644 --- a/src/frontend/lib/types/Filters.tsx +++ b/src/frontend/lib/types/Filters.tsx @@ -41,6 +41,7 @@ export type TableFilter = { name: string; label?: string; description?: string; + placeholder?: string; type?: TableFilterType; choices?: TableFilterChoice[]; choiceFunction?: () => TableFilterChoice[]; @@ -52,6 +53,8 @@ export type TableFilter = { apiFilter?: Record; model?: ModelType; modelRenderer?: (instance: any) => string; + transform?: (item: any) => TableFilterChoice; + multi?: boolean; }; /* diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index b918205b97..03acfb0ad9 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -99,7 +99,8 @@ export type ApiFormFieldType = { | 'file upload' | 'nested object' | 'dependent field' - | 'table'; + | 'table' + | 'tags'; api_url?: string; pk_field?: string; model?: ModelType; diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 32acc3bdad..adffd6063e 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -17,6 +17,7 @@ import { NestedObjectField } from './NestedObjectField'; import NumberField from './NumberField'; import { RelatedModelField } from './RelatedModelField'; import { TableField } from './TableField'; +import TagsField from './TagsField'; import TextField from './TextField'; /** @@ -249,6 +250,10 @@ export function ApiFormField({ control={controller} /> ); + case 'tags': + return ( + + ); default: return ( diff --git a/src/frontend/src/components/forms/fields/BooleanField.tsx b/src/frontend/src/components/forms/fields/BooleanField.tsx index c05b04f393..e293d82b3c 100644 --- a/src/frontend/src/components/forms/fields/BooleanField.tsx +++ b/src/frontend/src/components/forms/fields/BooleanField.tsx @@ -34,13 +34,13 @@ export function BooleanField({ // Coerce the value to a (stringified) boolean value const booleanValue: boolean = useMemo(() => { - return isTrue(value); + return isTrue(value ?? definition.default ?? false); }, [value]); return ( ; + definition: ApiFormFieldType; +}>) { + const { + field, + fieldState: { error } + } = controller; + + const [tags, setTags] = useState(field.value ?? []); + const [searchValue, setSearchValue] = useState(''); + const [debouncedSearch] = useDebouncedValue(searchValue, 250); + + // Sync inbound value changes (e.g. when initial data is loaded) + useEffect(() => { + setTags(field.value ?? []); + }, [field.value]); + + const onChange = useCallback( + (value: string[]) => { + setTags(value); + field.onChange(value); + definition.onValueChange?.(value); + }, + [field, definition] + ); + + const tagQuery = useQuery({ + queryKey: ['tags-autocomplete', debouncedSearch], + queryFn: () => + api + .get(apiUrl(ApiEndpoints.tag_list), { + params: { search: debouncedSearch, limit: 20 } + }) + .then((r) => r.data) + }); + + const suggestions: string[] = useMemo(() => { + const results: any[] = tagQuery.data?.results ?? tagQuery.data ?? []; + return results.map((tag: any) => String(tag.name)); + }, [tagQuery.data]); + + const reducedDefinition: any = useMemo(() => { + return { + ...definition, + allow_null: undefined, + allow_blank: undefined + }; + }, [definition]); + + return ( + + ); +} diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index 643f631e25..157f51979a 100644 --- a/src/frontend/src/components/render/Generic.tsx +++ b/src/frontend/src/components/render/Generic.tsx @@ -56,6 +56,12 @@ export function RenderError({ return instance && ; } +export function RenderTag({ + instance +}: Readonly): ReactNode { + return instance && ; +} + export function RenderImportSession({ instance }: { diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 258f800c58..8e8353781e 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -55,7 +55,8 @@ import { RenderParameterTemplate, RenderProjectCode, RenderSelectionEntry, - RenderSelectionList + RenderSelectionList, + RenderTag } from './Generic'; import { RenderPurchaseOrder, @@ -116,7 +117,8 @@ export const RendererLookup: ModelRendererDict = { [ModelType.contenttype]: RenderContentType, [ModelType.selectionlist]: RenderSelectionList, [ModelType.selectionentry]: RenderSelectionEntry, - [ModelType.error]: RenderError + [ModelType.error]: RenderError, + [ModelType.tag]: RenderTag }; /** diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 0deea93e13..c1ad970684 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -36,6 +36,7 @@ import { } from '../hooks/UseGenerator'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { RenderPartColumn } from '../tables/ColumnRenderers'; +import { TagsField } from './CommonFields'; /** * Field set for BuildOrder forms @@ -124,6 +125,7 @@ export function useBuildOrderFields({ }, value: destination }, + tags: TagsField({}), link: { icon: }, diff --git a/src/frontend/src/forms/CommonFields.tsx b/src/frontend/src/forms/CommonFields.tsx new file mode 100644 index 0000000000..9fcd5fd25b --- /dev/null +++ b/src/frontend/src/forms/CommonFields.tsx @@ -0,0 +1,19 @@ +import type { ApiFormFieldType } from '@lib/types/Forms'; +import { t } from '@lingui/core/macro'; + +export function TagsField({ + label, + description, + placeholder +}: Readonly<{ + label?: string; + description?: string; + placeholder?: string; +}>): ApiFormFieldType { + return { + field_type: 'tags', + label: label ?? t`Tags`, + description: description ?? t`Tags for this item`, + placeholder: placeholder ?? t`Select tags` + }; +} diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 9cefd6147f..14d3b6218f 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -13,6 +13,7 @@ import { IconPhone } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; +import { TagsField } from './CommonFields'; /** * Field set for SupplierPart instance @@ -82,6 +83,7 @@ export function useSupplierPartFields({ icon: }, description: {}, + tags: TagsField({}), link: { icon: }, @@ -117,6 +119,7 @@ export function useManufacturerPartFields() { }, MPN: {}, description: {}, + tags: TagsField({}), link: {} }; @@ -143,6 +146,7 @@ export function companyFields(): ApiFormFieldSet { email: { icon: }, + tags: TagsField({}), tax_id: {}, is_supplier: {}, is_manufacturer: {}, diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 35836e94ab..311b64b18f 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -3,6 +3,7 @@ import { t } from '@lingui/core/macro'; import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react'; import { useEffect, useMemo, useState } from 'react'; import { useGlobalSettingsState } from '../states/SettingsStates'; +import { TagsField } from './CommonFields'; /** * Construct a set of fields for creating / editing a Part instance @@ -54,6 +55,7 @@ export function usePartFields({ } }, keywords: {}, + tags: TagsField({}), units: {}, link: {}, default_location: { diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index d12916a49b..4faf5756ad 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -57,6 +57,7 @@ import { useSerialNumberGenerator } from '../hooks/UseGenerator'; import { useGlobalSettingsState } from '../states/SettingsStates'; +import { TagsField } from './CommonFields'; /* * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance */ @@ -287,6 +288,7 @@ export function usePurchaseOrderFields({ structural: false } }, + tags: TagsField({}), link: {}, contact: { icon: , diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index 2f5be88aa8..ea919f5da7 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -23,6 +23,7 @@ import { Thumbnail } from '../components/images/Thumbnail'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { StatusFilterOptions } from '../tables/Filter'; +import { TagsField } from './CommonFields'; export function useReturnOrderFields({ duplicateOrderId @@ -52,6 +53,7 @@ export function useReturnOrderFields({ icon: }, link: {}, + tags: TagsField({}), contact: { icon: , adjustFilters: (value: ApiFormAdjustFilterType) => { diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index ecfe82993c..125ca7f894 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -31,6 +31,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { useUserState } from '../states/UserState'; import { RenderPartColumn } from '../tables/ColumnRenderers'; +import { TagsField } from './CommonFields'; export function useSalesOrderFields({ duplicateOrderId @@ -64,6 +65,7 @@ export function useSalesOrderFields({ target_date: { icon: }, + tags: TagsField({}), link: {}, contact: { icon: , @@ -537,6 +539,7 @@ export function useSalesOrderShipmentFields({ }, tracking_number: {}, invoice_number: {}, + tags: TagsField({}), link: {} }; }, [customerId, pending]); diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index fe209f11c9..99255cf0d5 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -61,6 +61,7 @@ import { import useStatusCodes from '../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { StatusFilterOptions } from '../tables/Filter'; +import { TagsField } from './CommonFields'; /** * Construct a set of fields for creating / editing a StockItem instance @@ -272,6 +273,7 @@ export function useStockFields({ packaging: { icon: }, + tags: TagsField({}), link: { icon: }, diff --git a/src/frontend/src/forms/TransferOrderForms.tsx b/src/frontend/src/forms/TransferOrderForms.tsx index a7e3c8be3f..3935d89b92 100644 --- a/src/frontend/src/forms/TransferOrderForms.tsx +++ b/src/frontend/src/forms/TransferOrderForms.tsx @@ -10,6 +10,7 @@ import type { TableFieldRowProps } from '../components/forms/fields/TableField'; import { useCreateApiFormModal } from '../hooks/UseForm'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { RenderPartColumn } from '../tables/ColumnRenderers'; +import { TagsField } from './CommonFields'; export function useTransferOrderFields({ duplicateOrderId @@ -36,6 +37,7 @@ export function useTransferOrderFields({ } }, consume: {}, + tags: TagsField({}), link: {}, responsible: { filters: { diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 7460634ccb..f249332eb9 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -21,6 +21,7 @@ import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl } from '@lib/functions/Navigation'; +import { TagsList } from '@lib/index'; import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; @@ -228,7 +229,8 @@ export default function BuildDetail() { endpoint: ApiEndpoints.build_order_list, pk: id, params: { - part_detail: true + part_detail: true, + tags: true }, refetchOnMount: true }); @@ -438,17 +440,20 @@ export default function BuildDetail() { return ( - - - - - - + + + + + + + + + @@ -612,6 +617,7 @@ export default function BuildDetail() { title: t`Edit Build Order`, modalId: 'edit-build-order', fields: editBuildOrderFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index b23b8c19fd..2b779d4112 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -18,6 +18,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import { TagsList } from '@lib/index'; import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; import { PrintingActions } from '../../components/buttons/PrintingActions'; @@ -78,7 +79,9 @@ export default function CompanyDetail(props: Readonly) { } = useInstance({ endpoint: ApiEndpoints.company_list, pk: id, - params: {}, + params: { + tags: true + }, refetchOnMount: true }); @@ -153,23 +156,26 @@ export default function CompanyDetail(props: Readonly) { return ( - - - - - - + + + + + + + + + ); @@ -288,6 +294,7 @@ export default function CompanyDetail(props: Readonly) { pk: company?.pk, title: t`Edit Company`, fields: companyFields(), + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index 66e1a1a347..33b00ba470 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -8,6 +8,7 @@ import { import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import TagsList from '@lib/components/TagsList'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -59,7 +60,8 @@ export default function ManufacturerPartDetail() { hasPrimaryKey: true, params: { part_detail: true, - manufacturer_detail: true + manufacturer_detail: true, + tags: true } }); @@ -133,20 +135,23 @@ export default function ManufacturerPartDetail() { return ( - - - - - - + + + + + + + + + ); @@ -211,6 +216,7 @@ export default function ManufacturerPartDetail() { pk: manufacturerPart?.pk, title: t`Edit Manufacturer Part`, fields: editManufacturerPartFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index c61f4eaf32..4f16295460 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -9,6 +9,7 @@ import { import { type ReactNode, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import TagsList from '@lib/components/TagsList'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -67,7 +68,8 @@ export default function SupplierPartDetail() { params: { part_detail: true, supplier_detail: true, - manufacturer_detail: true + manufacturer_detail: true, + tags: true } }); @@ -221,20 +223,23 @@ export default function SupplierPartDetail() { return ( - - - - - - + + + + + + + + + @@ -339,6 +344,7 @@ export default function SupplierPartDetail() { pk: supplierPart?.pk, title: t`Edit Supplier Part`, fields: supplierPartFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index c53588a4a1..5182a1c249 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -41,6 +41,7 @@ import { type ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import Select from 'react-select'; +import TagsList from '@lib/components/TagsList'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -190,7 +191,8 @@ export default function PartDetail() { endpoint: ApiEndpoints.part_list, pk: id, params: { - path_detail: true + path_detail: true, + tags: true }, refetchOnMount: true }); @@ -612,6 +614,7 @@ export default function PartDetail() { + {enableRevisionSelection && ( @@ -998,6 +1001,7 @@ export default function PartDetail() { pk: part.pk, title: t`Edit Part`, fields: partFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index db4fa6107f..99099bad1a 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import { TagsList } from '@lib/index'; import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; @@ -65,7 +66,8 @@ export default function PurchaseOrderDetail() { endpoint: ApiEndpoints.purchase_order_list, pk: id, params: { - supplier_detail: true + supplier_detail: true, + tags: true }, refetchOnMount: true }); @@ -89,6 +91,7 @@ export default function PurchaseOrderDetail() { pk: id, title: t`Edit Purchase Order`, fields: purchaseOrderFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: () => { refreshInstance(); } @@ -318,17 +321,20 @@ export default function PurchaseOrderDetail() { return ( - - - - - - + + + + + + + + + diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index e7804454b0..67c1eba98f 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import { TagsList } from '@lib/index'; import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; @@ -66,7 +67,8 @@ export default function ReturnOrderDetail() { endpoint: ApiEndpoints.return_order_list, pk: id, params: { - customer_detail: true + customer_detail: true, + tags: true } }); @@ -296,17 +298,20 @@ export default function ReturnOrderDetail() { return ( - - - - - - + + + + + + + + + @@ -403,6 +408,7 @@ export default function ReturnOrderDetail() { pk: order.pk, title: t`Edit Return Order`, fields: returnOrderFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: () => { refreshInstance(); } diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index d0da141e87..ce5c4ac094 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -11,6 +11,7 @@ import { type ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { StylishText } from '@lib/components/StylishText'; +import TagsList from '@lib/components/TagsList'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; @@ -75,7 +76,8 @@ export default function SalesOrderDetail() { endpoint: ApiEndpoints.sales_order_list, pk: id, params: { - customer_detail: true + customer_detail: true, + tags: true } }); @@ -287,17 +289,20 @@ export default function SalesOrderDetail() { return ( - - - - - - + + + + + + + + + @@ -325,6 +330,7 @@ export default function SalesOrderDetail() { pk: order.pk, title: t`Edit Sales Order`, fields: salesOrderFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: () => { refreshInstance(); } diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index e5065e726a..7d181814e8 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -13,6 +13,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { getDetailUrl } from '@lib/functions/Navigation'; +import { TagsList } from '@lib/index'; import type { PanelType } from '@lib/types/Panel'; import AdminButton from '../../components/buttons/AdminButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; @@ -68,7 +69,8 @@ export default function SalesOrderShipmentDetail() { endpoint: ApiEndpoints.sales_order_shipment_list, pk: id, params: { - order_detail: true + order_detail: true, + tags: true } }); @@ -221,23 +223,26 @@ export default function SalesOrderShipmentDetail() { return ( <> - - - - - - + + + + + + + + + @@ -295,6 +300,7 @@ export default function SalesOrderShipmentDetail() { pk: shipment.pk, fields: editShipmentFields, title: t`Edit Shipment`, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshShipment }); diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index 5a3f0e50c7..eef0eb6e0b 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -3,6 +3,7 @@ import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl } from '@lib/functions/Navigation'; +import type { TableFilter } from '@lib/index'; import type { StockOperationProps } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import { t } from '@lingui/core/macro'; @@ -60,6 +61,21 @@ import { StockLocationTable } from '../../tables/stock/StockLocationTable'; import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable'; import { TransferOrderTable } from '../../tables/stock/TransferOrderTable'; +function TransferOrderCalendar() { + const calendarFilters: TableFilter[] = useMemo(() => { + return []; + }, []); + + return ( + + ); +} + export default function Stock() { const { id: _id } = useParams(); @@ -247,13 +263,7 @@ export default function Stock() { value: 'calendar', label: t`Calendar View`, icon: , - content: ( - - ) + content: }, { value: 'parametric', diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index a8e2ed47b1..451b787afa 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -35,6 +35,7 @@ import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation'; +import { TagsList } from '@lib/index'; import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms'; import type { PanelType } from '@lib/types/Panel'; import { notifications } from '@mantine/notifications'; @@ -118,7 +119,8 @@ export default function StockDetail() { params: { part_detail: true, location_detail: true, - path_detail: true + path_detail: true, + tags: true } }); @@ -445,19 +447,23 @@ export default function StockDetail() { return ( - - - - - - + + + + + + + + + @@ -702,6 +708,7 @@ export default function StockDetail() { title: t`Edit Stock Item`, modalId: 'edit-stock-item', fields: editStockItemFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 5d06fa2732..f699e6bf06 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; -import { type PanelType, apiUrl } from '@lib/index'; +import { type PanelType, TagsList, apiUrl } from '@lib/index'; import { IconBookmark, IconInfoCircle, @@ -62,7 +62,9 @@ export default function TransferOrderDetail() { } = useInstance({ endpoint: ApiEndpoints.transfer_order_list, pk: id, - params: {} + params: { + tags: true + } }); const toStatus = useStatusCodes({ modelType: ModelType.transferorder }); @@ -233,18 +235,21 @@ export default function TransferOrderDetail() { return ( - - {/* TODO: what image do we show for a Transfer Order? */} - {/* + + {/* TODO: what image do we show for a Transfer Order? */} + {/* */} - - - - + + + + + + @@ -369,6 +374,7 @@ export default function TransferOrderDetail() { pk: order.pk, title: t`Edit Transfer Order`, fields: transferOrderFields, + queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: () => { refreshInstance(); } diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index 81221bd2d2..83957096ee 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -399,6 +399,29 @@ export function ResponsibleFilter(): TableFilter { }); } +export function TagsFilter({ + modelType +}: { + modelType?: ModelType; +}): TableFilter { + return { + name: 'tags', + label: t`Tags`, + description: t`Filter by tags`, + placeholder: t`Select tags`, + type: 'api', + multi: true, + apiUrl: apiUrl(ApiEndpoints.tag_list), + model: ModelType.tag, + modelRenderer: (instance: any) => instance.name, + apiFilter: modelType ? { model_type: modelType } : undefined, + transform: (item: any) => ({ + value: item.name.toString(), + label: item.name.toString() + }) + }; +} + export function UserFilter({ name, label, diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx index 6113f013b1..81b02e3af6 100644 --- a/src/frontend/src/tables/FilterSelectDrawer.tsx +++ b/src/frontend/src/tables/FilterSelectDrawer.tsx @@ -7,6 +7,7 @@ import { Divider, Drawer, Group, + MultiSelect, Paper, Select, Space, @@ -16,6 +17,7 @@ import { Tooltip } from '@mantine/core'; import { DateInput, type DateValue } from '@mantine/dates'; +import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -27,6 +29,7 @@ import type { TableFilterType } from '@lib/types/Filters'; import { IconCheck } from '@tabler/icons-react'; +import { api } from '../App'; import { StandaloneField } from '../components/forms/StandaloneField'; import { filterDisplayLabel, @@ -106,6 +109,74 @@ function FilterItem({ ); } +/* + * Multi-select element for 'api' filters with multi=true. + * Fetches all options from the API then renders a searchable MultiSelect. + * The user picks multiple values and confirms with an Apply button. + */ +function MultiApiFilterElement({ + filterProps, + onValueChange +}: Readonly<{ + filterProps: TableFilter; + onValueChange: (value: string | null, displayValue?: any) => void; +}>) { + const [selected, setSelected] = useState([]); + + const query = useQuery({ + queryKey: ['filter-options', filterProps.apiUrl, filterProps.apiFilter], + queryFn: () => + api + .get(filterProps.apiUrl ?? '', { params: filterProps.apiFilter }) + .then((r) => r.data), + enabled: !!filterProps.apiUrl + }); + + const options: TableFilterChoice[] = useMemo(() => { + const results: any[] = query.data?.results ?? query.data ?? []; + if (filterProps.transform) { + return results.map(filterProps.transform); + } + return results.map((item: any) => ({ + value: String(item.pk ?? item.id ?? item.slug ?? item.value), + label: filterProps.modelRenderer?.(item) ?? String(item.name ?? item.pk) + })); + }, [query.data, filterProps.transform, filterProps.modelRenderer]); + + const apply = useCallback(() => { + if (!selected.length) return; + const labels = selected.map( + (v) => options.find((o) => o.value === v)?.label ?? v + ); + onValueChange(selected.join(','), labels.join(', ')); + }, [selected, options, onValueChange]); + + return ( + + + + } + /> + ); +} + function FilterElement({ filterName, filterProps, @@ -133,6 +204,14 @@ function FilterElement({ switch (filterProps.type) { case 'api': + if (filterProps.multi) { + return ( + + ); + } return ( { - onValueChange(value, filterProps.modelRenderer?.(instance)); + if (filterProps.transform) { + const choice = filterProps.transform(instance); + onValueChange(choice.value, choice.label); + } else { + onValueChange( + value, + filterProps.modelRenderer?.(instance) ?? value + ); + } } }} /> diff --git a/src/frontend/src/tables/build/BuildOrderFilters.tsx b/src/frontend/src/tables/build/BuildOrderFilters.tsx index 616d43c16c..c7f1fd8ee1 100644 --- a/src/frontend/src/tables/build/BuildOrderFilters.tsx +++ b/src/frontend/src/tables/build/BuildOrderFilters.tsx @@ -22,6 +22,7 @@ import { ResponsibleFilter, StartDateAfterFilter, StartDateBeforeFilter, + TagsFilter, TargetDateAfterFilter, TargetDateBeforeFilter } from '../Filter'; @@ -49,7 +50,8 @@ export default function BuildOrderFilters({ HasProjectCodeFilter(), IssuedByFilter(), ResponsibleFilter(), - PartCategoryFilter() + PartCategoryFilter(), + TagsFilter({ modelType: ModelType.build }) ]; const dateFilters: TableFilter[] = [ diff --git a/src/frontend/src/tables/company/CompanyTable.tsx b/src/frontend/src/tables/company/CompanyTable.tsx index 9856579709..f4a833955f 100644 --- a/src/frontend/src/tables/company/CompanyTable.tsx +++ b/src/frontend/src/tables/company/CompanyTable.tsx @@ -22,6 +22,7 @@ import { CompanyColumn, DescriptionColumn } from '../ColumnRenderers'; +import { TagsFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /** @@ -115,7 +116,8 @@ export function CompanyTable({ name: 'is_customer', label: t`Customer`, description: t`Show companies which are customers` - } + }, + TagsFilter({ modelType: ModelType.company }) ]; }, []); diff --git a/src/frontend/src/tables/part/PartTableFilters.tsx b/src/frontend/src/tables/part/PartTableFilters.tsx index ea1f113471..9435e7d904 100644 --- a/src/frontend/src/tables/part/PartTableFilters.tsx +++ b/src/frontend/src/tables/part/PartTableFilters.tsx @@ -1,5 +1,7 @@ +import { ModelType } from '@lib/enums/ModelType'; import type { TableFilter } from '@lib/types/Filters'; import { t } from '@lingui/core/macro'; +import { TagsFilter } from '../Filter'; /** * Construct a set of filters for the part table @@ -141,6 +143,9 @@ export function PartTableFilters(): TableFilter[] { label: t`Subscribed`, description: t`Filter by parts to which the user is subscribed`, type: 'boolean' - } + }, + TagsFilter({ + modelType: ModelType.part + }) ]; } diff --git a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx index 34db4ccab7..5825bf97a4 100644 --- a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx +++ b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx @@ -29,6 +29,7 @@ import { LinkColumn, PartColumn } from '../ColumnRenderers'; +import { TagsFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /* @@ -161,7 +162,8 @@ export function ManufacturerPartTable({ active: !manufacturerId, description: t`Show manufacturer parts for active manufacturers.`, type: 'boolean' - } + }, + TagsFilter({ modelType: ModelType.manufacturerpart }) ]; }, [manufacturerId]); diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderFilters.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderFilters.tsx index 16cfaa4761..348878046a 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderFilters.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderFilters.tsx @@ -19,6 +19,7 @@ import { ResponsibleFilter, StartDateAfterFilter, StartDateBeforeFilter, + TagsFilter, TargetDateAfterFilter, TargetDateBeforeFilter, UpdatedAfterFilter, @@ -38,7 +39,8 @@ export default function PurchaseOrderFilters({ ProjectCodeFilter(), HasProjectCodeFilter(), ResponsibleFilter(), - CreatedByFilter() + CreatedByFilter(), + TagsFilter({ modelType: ModelType.purchaseorder }) ]; const dateFilters: TableFilter[] = [ diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 4cae53fa9f..8dbf7337ea 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -37,6 +37,7 @@ import { NoteColumn, PartColumn } from '../ColumnRenderers'; +import { TagsFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -60,7 +61,8 @@ export function SupplierPartTable({ { name: 'active', value: 'true' - } + }, + TagsFilter({ modelType: ModelType.supplierpart }) ]; if (!supplierId) { diff --git a/src/frontend/src/tables/sales/ReturnOrderFilters.tsx b/src/frontend/src/tables/sales/ReturnOrderFilters.tsx index 0393949bae..97c1e02259 100644 --- a/src/frontend/src/tables/sales/ReturnOrderFilters.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderFilters.tsx @@ -20,6 +20,7 @@ import { ResponsibleFilter, StartDateAfterFilter, StartDateBeforeFilter, + TagsFilter, TargetDateAfterFilter, TargetDateBeforeFilter, UpdatedAfterFilter, @@ -41,7 +42,8 @@ export default function ReturnOrderFilters({ HasProjectCodeFilter(), ProjectCodeFilter(), ResponsibleFilter(), - CreatedByFilter() + CreatedByFilter(), + TagsFilter({ modelType: ModelType.returnorder }) ]; const dateFilters: TableFilter[] = [ diff --git a/src/frontend/src/tables/sales/SalesOrderFilters.tsx b/src/frontend/src/tables/sales/SalesOrderFilters.tsx index e6c1a38607..2146782b9c 100644 --- a/src/frontend/src/tables/sales/SalesOrderFilters.tsx +++ b/src/frontend/src/tables/sales/SalesOrderFilters.tsx @@ -20,6 +20,7 @@ import { ResponsibleFilter, StartDateAfterFilter, StartDateBeforeFilter, + TagsFilter, TargetDateAfterFilter, TargetDateBeforeFilter, UpdatedAfterFilter, @@ -41,7 +42,8 @@ export default function SalesOrderFilters({ HasProjectCodeFilter(), ProjectCodeFilter(), ResponsibleFilter(), - CreatedByFilter() + CreatedByFilter(), + TagsFilter({ modelType: ModelType.salesorder }) ]; const dateFilters: TableFilter[] = [ diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index 69922d0589..46712b967d 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -25,7 +25,6 @@ import type { TableColumn } from '@lib/types/Tables'; import { useCheckShipmentForm, useCompleteShipmentForm, - useSalesOrderShipmentCompleteFields, useSalesOrderShipmentFields, useUncheckShipmentForm } from '../../forms/SalesOrderForms'; @@ -41,6 +40,7 @@ import { LinkColumn, StatusColumn } from '../ColumnRenderers'; +import { TagsFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; export default function SalesOrderShipmentTable({ @@ -71,8 +71,6 @@ export default function SalesOrderShipmentTable({ pending: !selectedShipment.shipment_date }); - const completeShipmentFields = useSalesOrderShipmentCompleteFields({}); - const newShipment = useCreateApiFormModal({ url: ApiEndpoints.sales_order_shipment_list, fields: newShipmentFields, @@ -303,7 +301,8 @@ export default function SalesOrderShipmentTable({ name: 'delivered', label: t`Delivered`, description: t`Show shipments which have been delivered` - } + }, + TagsFilter({ modelType: ModelType.salesordershipment }) ]; }, []); diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 8e9f9c01da..9444d00bbc 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -44,6 +44,7 @@ import { SerialLTEFilter, StatusFilterOptions, SupplierFilter, + TagsFilter, UpdatedAfterFilter, UpdatedBeforeFilter } from '../Filter'; @@ -306,7 +307,8 @@ function stockItemTableFilters({ name: 'external', label: t`External Location`, description: t`Show items in an external location` - } + }, + TagsFilter({ modelType: ModelType.stockitem }) ]; } diff --git a/src/frontend/src/tables/stock/TransferOrderFilters.tsx b/src/frontend/src/tables/stock/TransferOrderFilters.tsx new file mode 100644 index 0000000000..f7d427d840 --- /dev/null +++ b/src/frontend/src/tables/stock/TransferOrderFilters.tsx @@ -0,0 +1,79 @@ +import { ModelType, type TableFilter } from '@lib/index'; +import { t } from '@lingui/core/macro'; +import { + AssignedToMeFilter, + CompletedAfterFilter, + CompletedBeforeFilter, + CreatedAfterFilter, + CreatedBeforeFilter, + CreatedByFilter, + HasProjectCodeFilter, + IncludeVariantsFilter, + MaxDateFilter, + MinDateFilter, + OrderStatusFilter, + OutstandingFilter, + OverdueFilter, + ProjectCodeFilter, + ResponsibleFilter, + StartDateAfterFilter, + StartDateBeforeFilter, + TagsFilter, + TargetDateAfterFilter, + TargetDateBeforeFilter +} from '../Filter'; + +export default function TransferOrderFilters({ + partId, + includeDateFilters = true +}: { + partId?: number; + includeDateFilters?: boolean; +}): TableFilter[] { + const filters: TableFilter[] = [ + OrderStatusFilter({ model: ModelType.transferorder }), + OutstandingFilter(), + OverdueFilter(), + AssignedToMeFilter(), + HasProjectCodeFilter(), + ProjectCodeFilter(), + ResponsibleFilter(), + CreatedByFilter(), + TagsFilter({ modelType: ModelType.transferorder }) + ]; + + const dateFilters: TableFilter[] = [ + MinDateFilter(), + MaxDateFilter(), + CreatedBeforeFilter(), + CreatedAfterFilter(), + TargetDateBeforeFilter(), + TargetDateAfterFilter(), + StartDateBeforeFilter(), + StartDateAfterFilter(), + { + name: 'has_target_date', + type: 'boolean', + label: t`Has Target Date`, + description: t`Show orders with a target date` + }, + { + name: 'has_start_date', + type: 'boolean', + label: t`Has Start Date`, + description: t`Show orders with a start date` + }, + CompletedBeforeFilter(), + CompletedAfterFilter() + ]; + + if (includeDateFilters) { + filters.push(...dateFilters); + } + + if (!!partId) { + filters.push(IncludeVariantsFilter()); + } + + return filters; +} diff --git a/src/frontend/src/tables/stock/TransferOrderTable.tsx b/src/frontend/src/tables/stock/TransferOrderTable.tsx index a799bf4e38..adb4f26343 100644 --- a/src/frontend/src/tables/stock/TransferOrderTable.tsx +++ b/src/frontend/src/tables/stock/TransferOrderTable.tsx @@ -22,28 +22,8 @@ import { StatusColumn, TargetDateColumn } from '../ColumnRenderers'; -import { - AssignedToMeFilter, - CompletedAfterFilter, - CompletedBeforeFilter, - CreatedAfterFilter, - CreatedBeforeFilter, - CreatedByFilter, - HasProjectCodeFilter, - IncludeVariantsFilter, - MaxDateFilter, - MinDateFilter, - OrderStatusFilter, - OutstandingFilter, - OverdueFilter, - ProjectCodeFilter, - ResponsibleFilter, - StartDateAfterFilter, - StartDateBeforeFilter, - TargetDateAfterFilter, - TargetDateBeforeFilter -} from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; +import TransferOrderFilters from './TransferOrderFilters'; export function TransferOrderTable({ partId @@ -56,45 +36,8 @@ export function TransferOrderTable({ const user = useUserState(); const tableFilters: TableFilter[] = useMemo(() => { - const filters: TableFilter[] = [ - OrderStatusFilter({ model: ModelType.transferorder }), - OutstandingFilter(), - OverdueFilter(), - AssignedToMeFilter(), - MinDateFilter(), - MaxDateFilter(), - CreatedBeforeFilter(), - CreatedAfterFilter(), - TargetDateBeforeFilter(), - TargetDateAfterFilter(), - StartDateBeforeFilter(), - StartDateAfterFilter(), - { - name: 'has_target_date', - type: 'boolean', - label: t`Has Target Date`, - description: t`Show orders with a target date` - }, - { - name: 'has_start_date', - type: 'boolean', - label: t`Has Start Date`, - description: t`Show orders with a start date` - }, - CompletedBeforeFilter(), - CompletedAfterFilter(), - HasProjectCodeFilter(), - ProjectCodeFilter(), - ResponsibleFilter(), - CreatedByFilter() - ]; - - if (!!partId) { - filters.push(IncludeVariantsFilter()); - } - - return filters; - }, [partId]); + return TransferOrderFilters({ includeDateFilters: true }); + }, []); const tableColumns = useMemo(() => { return [ diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index b54fadc6d7..0979f7cf07 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -120,6 +120,51 @@ test('Build Order - Basic Tests', async ({ browser }) => { .waitFor(); }); +// Test tags filtering against Build Orders +test('Build Order - Tags', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'manufacturing/index/buildorders' + }); + + // Filter by tag + await page + .getByRole('button', { name: 'segmented-icon-control-table' }) + .click(); + await clearTableFilters(page); + await page.getByRole('button', { name: 'table-select-filters' }).click(); + await page.getByRole('button', { name: 'Add Filter' }).click(); + await page.getByRole('combobox', { name: 'Filter' }).fill('tag'); + await page.getByRole('option', { name: 'Tags' }).click(); + await page.getByRole('combobox', { name: 'Value' }).click(); + + // Check for expected tags + await page.getByRole('option', { name: 'Furniture' }).waitFor(); + await page.getByRole('option', { name: 'Electronics' }).click(); + await page.getByRole('option', { name: 'PCB Assembly' }).click(); + + // Apply the "Furniture" tag filter + await page.getByRole('button', { name: 'apply-tags-filter' }).click(); + await page.getByRole('button', { name: 'filter-drawer-close' }).click(); + + // Check for expected results + await page.getByRole('cell', { name: 'BO0026' }).click(); + await page.getByText('100 x 002.01-PCBA | Widget').waitFor(); + + // Check for tags displayed on BuildOrder detail page + await page.getByText('Electronics', { exact: true }).first().waitFor(); + await page.getByText('PCB Assembly', { exact: true }).first().waitFor(); + + // Edit the build order + await page.keyboard.press('Control+E'); + + const tagsField = await page.getByRole('combobox', { + name: 'tags-field-tags' + }); + + await expect(tagsField).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); +}); + // Test that the build order reference field increments correctly test('Build Order - Reference', async ({ browser }) => { const page = await doCachedLogin(browser, { diff --git a/src/frontend/tests/pages/pui_sales.spec.ts b/src/frontend/tests/pages/pui_sales.spec.ts index ade92ec91b..9cc698fbe5 100644 --- a/src/frontend/tests/pages/pui_sales.spec.ts +++ b/src/frontend/tests/pages/pui_sales.spec.ts @@ -276,6 +276,29 @@ test('Sales Orders - Shipments', async ({ browser }) => { .click(); }); +// Filter Shipments by tag +test('Sales Orders - Shipments - Tags', async ({ browser }) => { + const page = await doCachedLogin(browser, { url: 'sales/index/shipments' }); + + // Filter by tag + await clearTableFilters(page); + await page.getByRole('button', { name: 'table-select-filters' }).click(); + await page.getByRole('button', { name: 'Add Filter' }).click(); + await page.getByRole('combobox', { name: 'Filter' }).fill('tag'); + await page.getByRole('option', { name: 'Tags' }).click(); + await page.getByRole('combobox', { name: 'Value' }).click(); + + // Apply the "Requires Payment" tag filter + await page.getByRole('option', { name: 'Requires Payment' }).click(); + await page.getByRole('button', { name: 'apply-tags-filter' }).click(); + await page.getByRole('button', { name: 'filter-drawer-close' }).click(); + + // Click through to one of the selected shipments + await page.getByRole('cell', { name: 'SO0007' }).click(); + await page.getByText('Sales Order: SO0007').first().waitFor(); + await page.getByText('Requires Payment', { exact: true }).first().waitFor(); +}); + // Complete a shipment against a sales order test('Sales Orders - Complete Shipment', async ({ browser }) => { const page = await doCachedLogin(browser, { From dae97f4795cd8c0552adea90e06446a64621f4f3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Jun 2026 20:32:24 +1000 Subject: [PATCH 2/8] [UI] Adjust calendar view color (#12080) - Match custom status colors --- .../src/components/calendar/OrderCalendar.tsx | 8 ++++-- .../src/components/render/StatusRenderer.tsx | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/calendar/OrderCalendar.tsx b/src/frontend/src/components/calendar/OrderCalendar.tsx index 95fd76abe3..8ecf517b6e 100644 --- a/src/frontend/src/components/calendar/OrderCalendar.tsx +++ b/src/frontend/src/components/calendar/OrderCalendar.tsx @@ -30,7 +30,7 @@ import { ProjectCodeFilter, ResponsibleFilter } from '../../tables/Filter'; -import { StatusRenderer } from '../render/StatusRenderer'; +import { StatusRenderer, getStatusColor } from '../render/StatusRenderer'; import Calendar from './Calendar'; /** @@ -107,6 +107,8 @@ export default function OrderCalendar({ order.start_date || order.issue_date || order.creation_date || today; const end: string = order.target_date || start; + const statusColor = getStatusColor(model, order.status); + return { order: order, id: order.pk, @@ -115,7 +117,9 @@ export default function OrderCalendar({ start: start, end: end, startEditable: canEdit, - durationEditable: canEdit + durationEditable: canEdit, + backgroundColor: statusColor, + borderColor: statusColor }; }) ?? [] ); diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index cf08aea6c7..171b97ab72 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -202,6 +202,31 @@ export const StatusRenderer = ({ return renderStatusLabel(status, statusCodes, options, fallbackStatus); }; +/* + * Return a CSS color string (Mantine CSS variable) for a given status code. + * Used to set event background colors in calendar views. + */ +export function getStatusColor( + type: ModelType | string, + status: string | number +): string { + const statusCodes = getStatusCodes(type); + const fallback = `var(--mantine-color-${statusColorMap['default']}-6)`; + + if (!statusCodes) return fallback; + + for (const name in statusCodes.values) { + const entry: StatusCodeInterface = statusCodes.values[name]; + if (entry?.key == status) { + const mantineColor = + statusColorMap[entry.color] ?? statusColorMap['default']; + return `var(--mantine-color-${mantineColor}-6)`; + } + } + + return fallback; +} + /* * Render the status badge in a table */ From 3dfd03fa89aa42d7d43bb00b2cd3f6d3428237ae Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Jun 2026 22:12:29 +1000 Subject: [PATCH 3/8] [Bug] Fix for build forms (#12081) * Bug fix for build forms - Memoize outputs to prevent re-rendering reset issues * Add playwright test --- src/frontend/src/forms/BuildForms.tsx | 49 ++++++++++++++-------- src/frontend/tests/pages/pui_build.spec.ts | 18 ++++++++ 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index c1ad970684..3fd3acbc41 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -343,16 +343,21 @@ export function useCompleteBuildOutputsForm({ ); }, [location, build.destination, build.part_detail]); + // Memoize the outputs once to avoid re-rendering issues + const buildOutputs = useMemo(() => { + return outputs.map((output: any) => { + return { + output: output.pk, + quantity: output.quantity + }; + }); + }, [outputs]); + const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => { return { outputs: { field_type: 'table', - value: outputs.map((output: any) => { - return { - output: output.pk, - quantity: output.quantity - }; - }), + value: buildOutputs, modelRenderer: (row: TableFieldRowProps) => { const record = outputs.find((output) => output.pk == row.item.output); return ( @@ -420,16 +425,21 @@ export function useScrapBuildOutputsForm({ ); }, [location, build.destination, build.part_detail]); + // Memoize the outputs once to avoid re-rendering issues + const buildOutputs = useMemo(() => { + return outputs.map((output: any) => { + return { + output: output.pk, + quantity: output.quantity + }; + }); + }, [outputs]); + const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => { return { outputs: { field_type: 'table', - value: outputs.map((output: any) => { - return { - output: output.pk, - quantity: output.quantity - }; - }), + value: buildOutputs, modelRenderer: (row: TableFieldRowProps) => { const record = outputs.find((output) => output.pk == row.item.output); return ( @@ -486,15 +496,20 @@ export function useCancelBuildOutputsForm({ outputs: any[]; onFormSuccess: (response: any) => void; }) { + // Memoize the outputs once to avoid re-rendering issues + const buildOutputs = useMemo(() => { + return outputs.map((output: any) => { + return { + output: output.pk + }; + }); + }, [outputs]); + const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => { return { outputs: { field_type: 'table', - value: outputs.map((output: any) => { - return { - output: output.pk - }; - }), + value: buildOutputs, modelRenderer: (row: TableFieldRowProps) => { const record = outputs.find((output) => output.pk == row.item.output); return ( diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 0979f7cf07..7243dd7994 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -379,6 +379,24 @@ test('Build Order - Build Outputs', async ({ browser }) => { ) .waitFor(); await page.getByRole('cell', { name: 'Quantity: 16' }).waitFor(); + + // Adjust the quantity field - we will only 'partially' scrap this output + await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('10'); + + // Next, adjust the "location" field - and check that the "quantity" field does not change + // Ref: https://github.com/inventree/InvenTree/pull/12081 + await page + .getByRole('combobox', { name: 'related-field-location' }) + .fill('factory'); + await page.getByTitle('Factory/Mechanical Lab').click(); + await page.waitForTimeout(250); + + // Check the 'quantity' value again - it should not have changed + const quantityValue = await page + .getByRole('textbox', { name: 'number-field-quantity' }) + .inputValue(); + expect(quantityValue).toBe('10'); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); }); From 1b8217e8b3ebb961c7aadf2b4a7ac49b37cf8263 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Jun 2026 22:19:28 +1000 Subject: [PATCH 4/8] [UI] Table filter set (#12079) * Save and load custom filter sets * Tweak UI logic * Adjust icons * More refactoring * Update UI docs * Playwright tests * Add docs image * Fix image name * Update docs/docs/concepts/user_interface.md Co-authored-by: Matthias Mair * Add CHANGELOG entry --------- Co-authored-by: Matthias Mair --- CHANGELOG.md | 1 + .../images/concepts/ui_table_filter_group.png | Bin 0 -> 29037 bytes docs/docs/concepts/user_interface.md | 18 ++ src/frontend/lib/hooks/UseFilterSet.tsx | 57 ++++- src/frontend/lib/types/Filters.tsx | 16 ++ .../src/tables/FilterSelectDrawer.tsx | 233 ++++++++++++++---- .../src/tables/build/BuildOrderFilters.tsx | 4 +- src/frontend/tests/pui_tables.spec.ts | 26 ++ 8 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 docs/docs/assets/images/concepts/ui_table_filter_group.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e18a582a7..fb4eaac156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface. - [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags. - [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation. - [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created. diff --git a/docs/docs/assets/images/concepts/ui_table_filter_group.png b/docs/docs/assets/images/concepts/ui_table_filter_group.png new file mode 100644 index 0000000000000000000000000000000000000000..0060f2bef7e523046f6ad47d01e462ac01cccd4c GIT binary patch literal 29037 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 bedgG6iFp!ZPm#cr81!{aZdKj9|LorY-7;hG literal 0 HcmV?d00001 diff --git a/docs/docs/concepts/user_interface.md b/docs/docs/concepts/user_interface.md index 32373e9e6c..80881e7766 100644 --- a/docs/docs/concepts/user_interface.md +++ b/docs/docs/concepts/user_interface.md @@ -158,6 +158,24 @@ Select the "table filters" button to open the filter selection menu Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view. +#### Saved Filter Groups + +Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually. + +The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard. + +{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }} + +Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions: + +- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters. +- **Delete** (red × icon): Permanently removes the saved filter group. + +Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices. + +!!! info "Loading a filter group replaces active filters" + Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten. + ### Data Sorting Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header. diff --git a/src/frontend/lib/hooks/UseFilterSet.tsx b/src/frontend/lib/hooks/UseFilterSet.tsx index 52749f0c54..01b2017862 100644 --- a/src/frontend/lib/hooks/UseFilterSet.tsx +++ b/src/frontend/lib/hooks/UseFilterSet.tsx @@ -1,6 +1,10 @@ import { useLocalStorage } from '@mantine/hooks'; import { useCallback, useEffect, useMemo } from 'react'; -import type { FilterSetState, TableFilter } from '../types/Filters'; +import type { + FilterSetState, + NamedFilterSet, + TableFilter +} from '../types/Filters'; export default function useFilterSet( filterKey: string, @@ -16,6 +20,16 @@ export default function useFilterSet( getInitialValueInEffect: false }); + // Named filter set snapshots (saved to local storage, separate key) + const [storedNamedSets, setStoredNamedSets] = useLocalStorage< + NamedFilterSet[] + >({ + key: `inventree-filtersets-${filterKey}`, + defaultValue: [], + sync: false, + getInitialValueInEffect: false + }); + useEffect(() => { if (storedFilters == null) { setStoredFilters(initialFilters || []); @@ -26,7 +40,6 @@ export default function useFilterSet( return storedFilters ?? initialFilters ?? []; }, [storedFilters, initialFilters]); - // Callback to clear all active filters from the table const clearActiveFilters = useCallback(() => { setStoredFilters([]); }, []); @@ -38,10 +51,48 @@ export default function useFilterSet( [setStoredFilters] ); + const saveFilterSet = useCallback( + (name: string) => { + const snapshot = activeFilters.map( + ({ name: n, value, displayValue }) => ({ + name: n, + value, + displayValue + }) + ); + setStoredNamedSets((prev) => { + const without = (prev ?? []).filter((s) => s.name !== name); + return [...without, { name, filters: snapshot }]; + }); + }, + [activeFilters, setStoredNamedSets] + ); + + const loadFilterSet = useCallback( + (name: string) => { + const saved = (storedNamedSets ?? []).find((s) => s.name === name); + if (saved) { + setStoredFilters(saved.filters as TableFilter[]); + } + }, + [storedNamedSets, setStoredFilters] + ); + + const deleteFilterSet = useCallback( + (name: string) => { + setStoredNamedSets((prev) => (prev ?? []).filter((s) => s.name !== name)); + }, + [setStoredNamedSets] + ); + return { filterKey, activeFilters, setActiveFilters, - clearActiveFilters + clearActiveFilters, + savedFilterSets: storedNamedSets ?? [], + saveFilterSet, + loadFilterSet, + deleteFilterSet }; } diff --git a/src/frontend/lib/types/Filters.tsx b/src/frontend/lib/types/Filters.tsx index afc07c79d9..1fe7806388 100644 --- a/src/frontend/lib/types/Filters.tsx +++ b/src/frontend/lib/types/Filters.tsx @@ -57,6 +57,14 @@ export type TableFilter = { multi?: boolean; }; +/* + * A named snapshot of a set of active filters, saved to local storage. + */ +export type NamedFilterSet = { + name: string; + filters: Pick[]; +}; + /* * Type definition for representing the state of a group of filters. * These may be applied to a data view (e.g. table, calendar) to filter the displayed data. @@ -65,10 +73,18 @@ export type TableFilter = { * activeFilters: An array of active filters * setActiveFilters: A function to set the active filters * clearActiveFilters: A function to clear all active filters + * savedFilterSets: Named filter set snapshots persisted to local storage + * saveFilterSet: Save the current active filters under a given name + * loadFilterSet: Replace active filters with a previously saved named set + * deleteFilterSet: Remove a saved named filter set by name */ export type FilterSetState = { filterKey: string; activeFilters: TableFilter[]; setActiveFilters: (filters: TableFilter[]) => void; clearActiveFilters: () => void; + savedFilterSets: NamedFilterSet[]; + saveFilterSet: (name: string) => void; + loadFilterSet: (name: string) => void; + deleteFilterSet: (name: string) => void; }; diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx index 81b02e3af6..415b2f4074 100644 --- a/src/frontend/src/tables/FilterSelectDrawer.tsx +++ b/src/frontend/src/tables/FilterSelectDrawer.tsx @@ -28,7 +28,12 @@ import type { TableFilterChoice, TableFilterType } from '@lib/types/Filters'; -import { IconCheck } from '@tabler/icons-react'; +import { + IconCheck, + IconFilterStar, + IconReload, + IconX +} from '@tabler/icons-react'; import { api } from '../App'; import { StandaloneField } from '../components/forms/StandaloneField'; import { @@ -402,6 +407,77 @@ function FilterAddGroup({ ); } +function SavedFilterSets({ + filterSet +}: Readonly<{ + filterSet: FilterSetState; +}>) { + if (filterSet.savedFilterSets.length === 0) { + return null; + } + + return ( + + + {t`Saved Filter Groups`} + + + {filterSet.savedFilterSets.map((set) => ( + + + + + + + + {set.name} + + + + + filterSet.loadFilterSet(set.name)} + > + + + + + filterSet.deleteFilterSet(set.name)} + > + + + + + + + ))} + + + ); +} + export function FilterSelectDrawer({ title, availableFilters, @@ -416,6 +492,8 @@ export function FilterSelectDrawer({ onClose: () => void; }>) { const [addFilter, setAddFilter] = useState(false); + const [saving, setSaving] = useState(false); + const [saveName, setSaveName] = useState(''); // Hide the "add filter" selection whenever the selected filters change useEffect(() => { @@ -423,11 +501,18 @@ export function FilterSelectDrawer({ }, [filterSet.activeFilters]); const hasFilters: boolean = useMemo(() => { - const filters = filterSet?.activeFilters ?? []; - - return filters.length > 0; + return (filterSet?.activeFilters ?? []).length > 0; }, [filterSet.activeFilters]); + const confirmSave = useCallback(() => { + const name = saveName.trim(); + if (name) { + filterSet.saveFilterSet(name); + } + setSaveName(''); + setSaving(false); + }, [saveName, filterSet]); + return ( {title ?? t`Table Filters`}} + styles={{ body: { height: '100%', overflow: 'hidden' } }} > - - {hasFilters && - filterSet.activeFilters?.map((f) => ( - - ))} - {addFilter && ( - - - - )} - {addFilter && ( - - )} - {!addFilter && - filterSet.activeFilters.length < availableFilters.length && ( + + + {hasFilters && + filterSet.activeFilters?.map((f) => ( + + ))} + {addFilter && ( + + + + )} + {addFilter && ( )} - {!addFilter && filterSet.activeFilters.length > 0 && ( - - )} + {!addFilter && + filterSet.activeFilters.length < availableFilters.length && ( + + )} + {!addFilter && hasFilters && ( + + )} + {!addFilter && + hasFilters && + (saving ? ( + + setSaveName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') confirmSave(); + if (e.key === 'Escape') setSaving(false); + }} + autoFocus + /> + + + + + + + setSaving(false)} + > + + + + + ) : ( + + ))} + + + + + ); diff --git a/src/frontend/src/tables/build/BuildOrderFilters.tsx b/src/frontend/src/tables/build/BuildOrderFilters.tsx index c7f1fd8ee1..69edccf4ac 100644 --- a/src/frontend/src/tables/build/BuildOrderFilters.tsx +++ b/src/frontend/src/tables/build/BuildOrderFilters.tsx @@ -44,8 +44,6 @@ export default function BuildOrderFilters({ OrderStatusFilter({ model: ModelType.build }), OverdueFilter(), AssignedToMeFilter(), - CompletedBeforeFilter(), - CompletedAfterFilter(), ProjectCodeFilter(), HasProjectCodeFilter(), IssuedByFilter(), @@ -57,6 +55,8 @@ export default function BuildOrderFilters({ const dateFilters: TableFilter[] = [ MinDateFilter(), MaxDateFilter(), + CompletedBeforeFilter(), + CompletedAfterFilter(), CreatedBeforeFilter(), CreatedAfterFilter(), TargetDateBeforeFilter(), diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index 9fbf0c8051..9e80ce5bb2 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -3,6 +3,7 @@ import { stevenuser } from './defaults.js'; import { clearTableFilters, navigate, + openFilterDrawer, setTableChoiceFilter, toggleColumnSorting } from './helpers.js'; @@ -39,6 +40,31 @@ test('Tables - Filters', async ({ browser }) => { await setTableChoiceFilter(page, 'Has Start Date', 'Yes'); await clearTableFilters(page); + + // Next, let's create a "custom filter group" and apply it + await openFilterDrawer(page); + await page.getByRole('button', { name: 'Add Filter' }).click(); + await page.getByRole('combobox', { name: 'Filter' }).click(); + await page.getByRole('option', { name: 'Outstanding' }).click(); + await page.getByRole('combobox', { name: 'Value' }).click(); + await page.getByRole('option', { name: 'Yes' }).click(); + + // Save the filter group + await page.getByRole('button', { name: 'Save current filters' }).click(); + await page.getByRole('textbox', { name: 'filter-group-name' }).fill('custom'); + await page + .getByRole('button', { name: 'save-filter-set', exact: true }) + .click(); + + // Clear filters, and then restore from saved group + await page.getByRole('button', { name: 'Clear Filters' }).click(); + await page.getByRole('button', { name: 'load-filter-group-custom' }).click(); + await page.getByText('Show outstanding items').first().waitFor(); + + // Remove the filter group + await page + .getByRole('button', { name: 'delete-filter-group-custom' }) + .click(); }); test('Tables - Pagination', async ({ browser }) => { From 2d8f735ac5be577064b7e3e63c3c6f6207fcdfb3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Jun 2026 14:33:56 +1000 Subject: [PATCH 5/8] Adjust label for Build Order (#12089) - Bring into line with other order types --- src/frontend/lib/enums/ModelInformation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index 9c14e42da9..5989e0acf9 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -118,8 +118,8 @@ export const ModelInformationDict: ModelDict = { icon: 'history' }, build: { - label: () => t`Build`, - label_multiple: () => t`Builds`, + label: () => t`Build Order`, + label_multiple: () => t`Build Orders`, url_overview: '/manufacturing/index/buildorders/', url_detail: '/manufacturing/build-order/:pk/', api_endpoint: ApiEndpoints.build_order_list, From 8d2b765013da247e558817202b164a0154a272fb Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Jun 2026 17:06:36 +1000 Subject: [PATCH 6/8] [UI] Adjust text (#12085) * Adjust text * Fix playwright test --- src/frontend/src/tables/FilterSelectDrawer.tsx | 2 +- src/frontend/tests/pui_tables.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx index 415b2f4074..2421e2328e 100644 --- a/src/frontend/src/tables/FilterSelectDrawer.tsx +++ b/src/frontend/src/tables/FilterSelectDrawer.tsx @@ -621,7 +621,7 @@ export function FilterSelectDrawer({ variant='light' onClick={() => setSaving(true)} > - {t`Save current filters`} + {t`Save Filters`} ))} diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index 9e80ce5bb2..ded7c1fe7c 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -50,7 +50,7 @@ test('Tables - Filters', async ({ browser }) => { await page.getByRole('option', { name: 'Yes' }).click(); // Save the filter group - await page.getByRole('button', { name: 'Save current filters' }).click(); + await page.getByRole('button', { name: 'Save Filters' }).click(); await page.getByRole('textbox', { name: 'filter-group-name' }).fill('custom'); await page .getByRole('button', { name: 'save-filter-set', exact: true }) From 969539426d91cd184abe18cf85a96aa8beb8f78c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Jun 2026 17:13:12 +1000 Subject: [PATCH 7/8] [UI] Calendar refresh (#12091) * [UI] Add "refresh" button to Calendar view * Extend filter drawer width --- .../src/components/calendar/Calendar.tsx | 17 ++++++++++++++++- .../src/components/calendar/OrderCalendar.tsx | 1 + src/frontend/src/tables/FilterSelectDrawer.tsx | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/calendar/Calendar.tsx b/src/frontend/src/components/calendar/Calendar.tsx index dcddcdcfd1..f097318403 100644 --- a/src/frontend/src/components/calendar/Calendar.tsx +++ b/src/frontend/src/components/calendar/Calendar.tsx @@ -32,7 +32,8 @@ import { IconChevronLeft, IconChevronRight, IconDownload, - IconFilter + IconFilter, + IconRefresh } from '@tabler/icons-react'; import { type ReactNode, @@ -55,6 +56,7 @@ export interface InvenTreeCalendarProps extends CalendarOptions { enableDownload?: boolean; enableFilters?: boolean; enableSearch?: boolean; + enableRefresh?: boolean; eventTooltipContent?: (event: EventContentArg) => ReactNode; filters?: TableFilter[]; isLoading?: boolean; @@ -65,6 +67,7 @@ export default function Calendar({ enableDownload, enableFilters = false, enableSearch, + enableRefresh = true, eventTooltipContent, isLoading, filters, @@ -214,6 +217,18 @@ export default function Calendar({ {enableSearch && ( )} + {enableRefresh && ( + state.query.refetch()} + > + + + + + )} {enableFilters && filters && filters.length > 0 && ( Date: Fri, 5 Jun 2026 18:08:27 +1000 Subject: [PATCH 8/8] [UI] Table adjuments (#12090) - Set stale time for build table queries - Fixes for table ordering --- src/frontend/src/tables/InvenTreeTable.tsx | 32 +++++++++++++------ .../src/tables/build/BuildOutputTable.tsx | 4 ++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index fc0395ffce..9bcabdd738 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -255,16 +255,22 @@ export function InvenTreeTableInternal>({ // Update column visibility when hiddenColumns change const dataColumns: any = useMemo(() => { - let cols: TableColumn[] = columns.filter((col) => col?.hidden != true); + // Include all columns (even prop-hidden ones) so useDataTableColumns always + // has the full ordered list. This prevents dynamic columns (e.g. ones whose + // hidden flag depends on async data) from being treated as brand-new columns + // after load, which would place them after the ACTIONS column. + let cols: TableColumn[] = [...columns]; cols = cols.map((col) => { - // If the column is *not* switchable, it is always visible - // Otherwise, check if it is "default hidden" - + // Prop-level hidden takes priority (e.g. hidden: !hasTrackedItems). + // For switchable columns, visibility is driven by tableState.hiddenColumns. + // Non-switchable columns are always visible (unless hidden by props). const hidden: boolean = - col.switchable == false - ? false - : (tableState.hiddenColumns?.includes(col.accessor) ?? false); + col.hidden === true + ? true + : col.switchable == false + ? false + : (tableState.hiddenColumns?.includes(col.accessor) ?? false); // Wrap the render function with CopyableCell if copyable is enabled const originalRender = col.render; @@ -382,11 +388,17 @@ export function InvenTreeTableInternal>({ getInitialValueInEffect: false }); - // Reset column ordering and custom widths when the component is mounted + // Reset column ordering when the column set changes (columns added/removed). // Ref: https://github.com/icflorescu/mantine-datatable/issues/759 useEffect(() => { - tableColumns.setColumnsOrder(dataColumns.map((col: any) => col.accessor)); - }, [tableColumnNames]); + const savedOrder = tableColumns.columnsOrder.join(','); + + if (savedOrder != tableColumnNames) { + // This is covering the edge case where the column order is not updated automatically + tableColumns.resetColumnsOrder(); + tableColumns.setColumnsOrder(tableColumnNames.split(',')); + } + }, [tableColumnNames, tableColumns.columnsOrder]); // Reset the pagination state when the search term changes useEffect(() => { diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 6367564acb..42d3cac02a 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -170,7 +170,8 @@ export default function BuildOutputTable({ // Fetch the test templates associated with the partId const { data: testTemplates, refetch: refetchTestTemplates } = useQuery({ - queryKey: ['buildoutputtests', partId, build], + staleTime: 60 * 1000, // Cache for 1 minute + queryKey: ['buildoutputtests', partId, buildId], queryFn: async () => { if (!partId || partId < 0) { return []; @@ -200,6 +201,7 @@ export default function BuildOutputTable({ // Fetch the "tracked" BOM items associated with the partId const { data: trackedItems, refetch: refetchTrackedItems } = useQuery({ + staleTime: 60 * 1000, // Cache for 1 minute queryKey: ['trackeditems', buildId], queryFn: async () => { if (!buildId || buildId < 0) {