From 75a08a1e06c3f406f57a8a4200cfaaf0960ce88b Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Jun 2026 19:38:22 +1000 Subject: [PATCH] [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, {