From 599eea6dd3c9e54881cdaf762670d7eaad73bdd8 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 00:54:33 +0200 Subject: [PATCH 01/41] Add SelectionList model, APIs and simple tests --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/common/api.py | 74 +++++++ ...lectionlist_selectionlistentry_and_more.py | 190 ++++++++++++++++++ src/backend/InvenTree/common/models.py | 164 +++++++++++++++ src/backend/InvenTree/common/serializers.py | 35 ++++ src/backend/InvenTree/common/tests.py | 47 +++++ ...131_partparametertemplate_selectionlist.py | 28 +++ src/backend/InvenTree/part/models.py | 14 ++ 8 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py create mode 100644 src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f5e9237627c5..6685daf66865 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 249 +INVENTREE_API_VERSION = 250 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v250 - 2024-09-20 : https://github.com/inventree/InvenTree/pull/#### + - Adds "SelectionList" and "SelectionListEntry" API endpoints + v249 - 2024-08-23 : https://github.com/inventree/InvenTree/pull/7978 - Sort status enums diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 7d3a47ea8d1a..580e71107f25 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -775,6 +775,78 @@ def get_queryset(self): return get_icon_packs().values() +class SelectionListList(ListAPI): + """List view for SelectionList objects.""" + + queryset = common.models.SelectionList.objects.all() + serializer_class = common.serializers.SelectionListSerializer + permission_classes = [permissions.IsAuthenticated] + + +class SelectionListDetail(RetrieveUpdateDestroyAPI): + """Detail view for a SelectionList object.""" + + queryset = common.models.SelectionList.objects.all() + serializer_class = common.serializers.SelectionListSerializer + permission_classes = [permissions.IsAuthenticated] + + +class EntryMixin: + """Mixin for SelectionEntry views.""" + + queryset = common.models.SelectionListEntry.objects.all() + serializer_class = common.serializers.SelectionEntrySerializer + permission_classes = [permissions.IsAuthenticated] + lookup_url_kwarg = 'entrypk' + + def get_queryset(self): + """Prefetch related fields.""" + pk = self.kwargs.get('pk', None) + queryset = super().get_queryset().filter(list=pk) + queryset = queryset.prefetch_related('list') + return queryset + + +class SelectionEntryList(EntryMixin, ListCreateAPI): + """List view for SelectionEntry objects.""" + + +class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI): + """Detail view for a SelectionEntry object.""" + + +selection_urls = [ + path( + '/', + include([ + # Entries + path( + 'entry/', + include([ + path( + '/', + include([ + path( + '', + SelectionEntryDetail.as_view(), + name='api-selectionlistentry-detail', + ) + ]), + ), + path( + '', + SelectionEntryList.as_view(), + name='api-selectionlistentry-list', + ), + ]), + ), + path('', SelectionListDetail.as_view(), name='api-selectionlist-detail'), + ]), + ), + path('', SelectionListList.as_view(), name='api-selectionlist-list'), +] + +# API URL patterns settings_api_urls = [ # User settings path( @@ -982,6 +1054,8 @@ def get_queryset(self): ), # Icons path('icons/', IconList.as_view(), name='api-icon-list'), + # Selection lists + path('selection/', include(selection_urls)), ] admin_api_urls = [ diff --git a/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py b/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py new file mode 100644 index 000000000000..8343e67054e0 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py @@ -0,0 +1,190 @@ +# Generated by Django 4.2.15 on 2024-09-01 22:13 + +import django.db.models.deletion +from django.db import migrations, models + +import InvenTree.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plugin", "0009_alter_pluginconfig_key"), + ("common", "0029_inventreecustomuserstatemodel"), + ] + + operations = [ + migrations.CreateModel( + name="SelectionList", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "name", + models.CharField( + help_text="Name of the selection list", + max_length=100, + unique=True, + verbose_name="Name", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Description of the selection list", + max_length=250, + verbose_name="Description", + ), + ), + ( + "locked", + models.BooleanField( + default=False, + help_text="Is this selection list locked?", + verbose_name="Locked", + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Can this selection list be used?", + verbose_name="Active", + ), + ), + ( + "source_string", + models.CharField( + blank=True, + help_text="Source string for this selection lists entries", + max_length=1000, + verbose_name="Source String", + ), + ), + ( + "created", + models.DateTimeField( + auto_now_add=True, + help_text="Date and time that the selection list was created", + verbose_name="Created", + ), + ), + ( + "last_updated", + models.DateTimeField( + auto_now=True, + help_text="Date and time that the selection list was last updated", + verbose_name="Last Updated", + ), + ), + ], + options={ + "verbose_name": "Selection List", + "verbose_name_plural": "Selection Lists", + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + migrations.CreateModel( + name="SelectionListEntry", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "value", + models.CharField( + help_text="Value of the selection list entry", + max_length=255, + verbose_name="Value", + ), + ), + ( + "label", + models.CharField( + help_text="Label for the selection list entry", + max_length=255, + verbose_name="Label", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Description of the selection list entry", + max_length=250, + verbose_name="Description", + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Is this selection list entry active?", + verbose_name="Active", + ), + ), + ( + "list", + models.ForeignKey( + help_text="Selection list to which this entry belongs", + on_delete=django.db.models.deletion.CASCADE, + related_name="entries", + to="common.selectionlist", + verbose_name="Selection List", + ), + ), + ], + options={ + "verbose_name": "Selection List Entry", + "verbose_name_plural": "Selection List Entries", + "unique_together": {("list", "value")}, + }, + ), + migrations.AddField( + model_name="selectionlist", + name="default", + field=models.ForeignKey( + blank=True, + help_text="Default entry for this selection list", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="common.selectionlistentry", + verbose_name="Default Entry", + ), + ), + migrations.AddField( + model_name="selectionlist", + name="source_plugin", + field=models.ForeignKey( + blank=True, + help_text="Plugin which provides the selection list", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="plugin.pluginconfig", + verbose_name="Source Plugin", + ), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index ac13e8d3c638..c799373f9a9f 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3444,3 +3444,167 @@ def clean(self) -> None: }) return super().clean() + + +class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): + """Class which represents a list of selectable items for parameters. + + A lists selection options can be either manually defined, or sourced from a plugin. + + Attributes: + name: The name of the selection list + description: A description of the selection list + locked: Is this selection list locked (i.e. cannot be modified)? + active: Is this selection list active? + source_plugin: The plugin which provides the selection list + source_string: The string representation of the selection list + default: The default value for the selection list + created: The date/time that the selection list was created + last_updated: The date/time that the selection list was last updated + """ + + class Meta: + """Meta options for SelectionList.""" + + verbose_name = _('Selection List') + verbose_name_plural = _('Selection Lists') + + name = models.CharField( + max_length=100, + verbose_name=_('Name'), + help_text=_('Name of the selection list'), + unique=True, + ) + + description = models.CharField( + max_length=250, + verbose_name=_('Description'), + help_text=_('Description of the selection list'), + blank=True, + ) + + locked = models.BooleanField( + default=False, + verbose_name=_('Locked'), + help_text=_('Is this selection list locked?'), + ) + + active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_('Can this selection list be used?'), + ) + + source_plugin = models.ForeignKey( + 'plugin.PluginConfig', + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Source Plugin'), + help_text=_('Plugin which provides the selection list'), + ) + + source_string = models.CharField( + max_length=1000, + verbose_name=_('Source String'), + help_text=_('Source string for this selection lists entries'), + blank=True, + ) + + default = models.ForeignKey( + 'SelectionListEntry', + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Default Entry'), + help_text=_('Default entry for this selection list'), + ) + + created = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Created'), + help_text=_('Date and time that the selection list was created'), + ) + + last_updated = models.DateTimeField( + auto_now=True, + verbose_name=_('Last Updated'), + help_text=_('Date and time that the selection list was last updated'), + ) + + def __str__(self): + """Return string representation of the selection list.""" + if not self.active: + return f'{self.name} (Inactive)' + return self.name + + def get_api_url(self): + """Return the API URL associated with the SelectionList model.""" + return reverse('api-selectionlist-list') + + def get_choices(self): + """Return the choices for the selection list.""" + choices = SelectionListEntry.objects.filter(list=self, active=True) + return [(c.value, c.label) for c in choices] + + +class SelectionListEntry(models.Model): + """Class which represents a single entry in a SelectionList. + + Attributes: + list: The SelectionList to which this entry belongs + value: The value of the selection list entry + label: The label for the selection list entry + description: A description of the selection list entry + active: Is this selection list entry active? + """ + + class Meta: + """Meta options for SelectionListEntry.""" + + verbose_name = _('Selection List Entry') + verbose_name_plural = _('Selection List Entries') + unique_together = [['list', 'value']] + + list = models.ForeignKey( + SelectionList, + on_delete=models.CASCADE, + related_name='entries', + verbose_name=_('Selection List'), + help_text=_('Selection list to which this entry belongs'), + ) + + value = models.CharField( + max_length=255, + verbose_name=_('Value'), + help_text=_('Value of the selection list entry'), + ) + + label = models.CharField( + max_length=255, + verbose_name=_('Label'), + help_text=_('Label for the selection list entry'), + ) + + description = models.CharField( + max_length=250, + verbose_name=_('Description'), + help_text=_('Description of the selection list entry'), + blank=True, + ) + + active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_('Is this selection list entry active?'), + ) + + def __str__(self): + """Return string representation of the selection list entry.""" + if not self.active: + return f'{self.label} (Inactive)' + return self.label + + def get_api_url(self): + """Return the API URL associated with the SelectionListEntry model.""" + return reverse('api-selectionlistentry-list', kwargs={'pk': self.pk}) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index e0eec23a4997..13fca15d7609 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -617,3 +617,38 @@ class IconPackageSerializer(serializers.Serializer): prefix = serializers.CharField() fonts = serializers.DictField(child=serializers.CharField()) icons = serializers.DictField(child=IconSerializer()) + + +class SelectionEntrySerializer(InvenTreeModelSerializer): + """Serializer for a selection entry.""" + + class Meta: + """Meta options for SelectionEntrySerializer.""" + + model = common_models.SelectionListEntry + fields = '__all__' + + +class SelectionListSerializer(InvenTreeModelSerializer): + """Serializer for a selection list.""" + + class Meta: + """Meta options for SelectionListSerializer.""" + + model = common_models.SelectionList + fields = [ + 'pk', + 'name', + 'description', + 'active', + 'locked', + 'source_plugin', + 'source_string', + 'default', + 'created', + 'last_updated', + 'choices', + ] + + default = SelectionEntrySerializer(read_only=True, many=False) + choices = SelectionEntrySerializer(source='entries', read_only=True, many=True) diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 7acd3b9c9eb4..87a93fcc84e5 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -40,6 +40,8 @@ NotificationEntry, NotificationMessage, ProjectCode, + SelectionList, + SelectionListEntry, WebhookEndpoint, WebhookMessage, ) @@ -1675,3 +1677,48 @@ def test_validation(self): self.assertEqual( instance.__str__(), 'Stock Item (StockStatus): OK - advanced | 11 (10)' ) + + +class SelectionListTest(InvenTreeAPITestCase): + """Tests for the SelectionList and SelectionListEntry model and API endpoints.""" + + def setUp(self): + """Setup for all tests.""" + super().setUp() + + self.list = SelectionList.objects.create(name='Test List') + self.entry1 = SelectionListEntry.objects.create( + list=self.list, + value='test1', + label='Test Entry', + description='Test Description', + ) + self.entry2 = SelectionListEntry.objects.create( + list=self.list, + value='test2', + label='Test Entry 2', + description='Test Description 2', + active=False, + ) + + def test_api(self): + """Test the SelectionList and SelctionListEntry API endpoints.""" + url = reverse('api-selectionlist-list') + response = self.get(url, expected_code=200) + self.assertEqual(len(response.data), 1) + + url = reverse('api-selectionlist-detail', kwargs={'pk': self.list.pk}) + response = self.get(url, expected_code=200) + self.assertEqual(response.data['name'], 'Test List') + self.assertEqual(len(response.data['choices']), 2) + self.assertEqual(response.data['choices'][0]['value'], 'test1') + self.assertEqual(response.data['choices'][0]['label'], 'Test Entry') + + url = reverse( + 'api-selectionlistentry-detail', + kwargs={'entrypk': self.entry1.pk, 'pk': self.list.pk}, + ) + response = self.get(url, expected_code=200) + self.assertEqual(response.data['value'], 'test1') + self.assertEqual(response.data['label'], 'Test Entry') + self.assertEqual(response.data['description'], 'Test Description') diff --git a/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py b/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py new file mode 100644 index 000000000000..11bd35091a89 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.15 on 2024-09-01 22:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0030_selectionlist_selectionlistentry_and_more"), + ("part", "0130_alter_parttesttemplate_part"), + ] + + operations = [ + migrations.AddField( + model_name="partparametertemplate", + name="selectionlist", + field=models.ForeignKey( + blank=True, + help_text="Selection list for this parameter", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="parameter_templates", + to="common.selectionlist", + verbose_name="Selection List", + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 246d71080ce9..0266455b1cd4 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3685,6 +3685,7 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel): description: Description of the parameter [string] checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool] choices: List of valid choices for the parameter [string] + selectionlist: SelectionList that should be used for choices [selectionlist] """ class Meta: @@ -3766,6 +3767,9 @@ def validate_unique(self, exclude=None): def get_choices(self): """Return a list of choices for this parameter template.""" + if self.selectionlist: + return self.selectionlist.get_choices() + if not self.choices: return [] @@ -3806,6 +3810,16 @@ def get_choices(self): blank=True, ) + selectionlist = models.ForeignKey( + common.models.SelectionList, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='parameter_templates', + verbose_name=_('Selection List'), + help_text=_('Selection list for this parameter'), + ) + @receiver( post_save, From d07ff3b4d45eee4a65552abd08d812ce810fef28 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 01:30:49 +0200 Subject: [PATCH 02/41] Add managment entries --- src/backend/InvenTree/users/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 6ae90644d732..f41ba8514682 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -346,6 +346,8 @@ def get_ruleset_ignore(): 'common_webhookendpoint', 'common_webhookmessage', 'common_inventreecustomuserstatemodel', + 'common_selectionlistentry', + 'common_selectionlist', 'users_owner', # Third-party tables 'error_report_error', From 85adfad85d87cc2e10e147c6b45f560f532257ce Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 08:11:42 +0200 Subject: [PATCH 03/41] Add field to serializer --- src/backend/InvenTree/part/serializers.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index a587ce57e625..d851c473aa91 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -283,7 +283,16 @@ class Meta: """Metaclass defining serializer fields.""" model = PartParameterTemplate - fields = ['pk', 'name', 'units', 'description', 'parts', 'checkbox', 'choices'] + fields = [ + 'pk', + 'name', + 'units', + 'description', + 'parts', + 'checkbox', + 'choices', + 'selectionlist', + ] parts = serializers.IntegerField( read_only=True, From 4e3eb2d95d60533a427b771aab56bc81b1121e6b Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 09:10:28 +0200 Subject: [PATCH 04/41] add more tests for parameters --- src/backend/InvenTree/common/models.py | 2 +- src/backend/InvenTree/common/tests.py | 37 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index c799373f9a9f..a59b15aef819 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3545,7 +3545,7 @@ def get_api_url(self): def get_choices(self): """Return the choices for the selection list.""" choices = SelectionListEntry.objects.filter(list=self, active=True) - return [(c.value, c.label) for c in choices] + return [c.value for c in choices] class SelectionListEntry(models.Model): diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 87a93fcc84e5..c5001fd635d8 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -24,7 +24,7 @@ from common.settings import get_global_setting, set_global_setting from InvenTree.helpers import str2bool from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin -from part.models import Part +from part.models import Part, PartParameterTemplate from plugin import registry from plugin.models import NotificationUserSetting @@ -1682,6 +1682,8 @@ def test_validation(self): class SelectionListTest(InvenTreeAPITestCase): """Tests for the SelectionList and SelectionListEntry model and API endpoints.""" + fixtures = ['category', 'part', 'location', 'params', 'test_templates'] + def setUp(self): """Setup for all tests.""" super().setUp() @@ -1722,3 +1724,36 @@ def test_api(self): self.assertEqual(response.data['value'], 'test1') self.assertEqual(response.data['label'], 'Test Entry') self.assertEqual(response.data['description'], 'Test Description') + + def test_parameter(self): + """Test the SelectionList parameter.""" + self.assertEqual(self.list.get_choices(), ['test1']) + self.user.is_superuser = True + self.user.save() + + # Add to parameter + part = Part.objects.get(pk=1) + template = PartParameterTemplate.objects.create( + name='test_parameter', units='', selectionlist=self.list + ) + rsp = self.get( + reverse('api-part-parameter-template-detail', kwargs={'pk': template.pk}) + ) + self.assertEqual(rsp.data['name'], 'test_parameter') + self.assertEqual(rsp.data['choices'], '') + + # Add to part + url = reverse('api-part-parameter-list') + response = self.post( + url, + {'part': part.pk, 'template': template.pk, 'data': 70}, + expected_code=400, + ) + self.assertIn('Invalid choice for parameter value', response.data['data']) + + response = self.post( + url, + {'part': part.pk, 'template': template.pk, 'data': self.entry1.value}, + expected_code=201, + ) + self.assertEqual(response.data['data'], self.entry1.value) From 2c759edc665bcfb8cdf4fae37f9240a1f35b64e0 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 17:35:58 +0200 Subject: [PATCH 05/41] Add support for SelectionList to CUI --- src/backend/InvenTree/common/models.py | 3 ++- .../templates/js/translated/model_renderers.js | 14 ++++++++++++++ .../InvenTree/templates/js/translated/part.js | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index a59b15aef819..ad46e79134f8 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3538,7 +3538,8 @@ def __str__(self): return f'{self.name} (Inactive)' return self.name - def get_api_url(self): + @staticmethod + def get_api_url(): """Return the API URL associated with the SelectionList model.""" return reverse('api-selectionlist-list') diff --git a/src/backend/InvenTree/templates/js/translated/model_renderers.js b/src/backend/InvenTree/templates/js/translated/model_renderers.js index 67eb4186e793..6f4201eec5ac 100644 --- a/src/backend/InvenTree/templates/js/translated/model_renderers.js +++ b/src/backend/InvenTree/templates/js/translated/model_renderers.js @@ -99,6 +99,8 @@ function getModelRenderer(model) { return renderReportTemplate; case 'pluginconfig': return renderPluginConfig; + case 'selectionlist': + return renderSelectionList; default: // Un-handled model type console.error(`Rendering not implemented for model '${model}'`); @@ -586,3 +588,15 @@ function renderPluginConfig(data, parameters={}) { parameters ); } + +// Render for "SelectionList" model +function renderSelectionList(data, parameters={}) { + + return renderModel( + { + text: data.name, + textSecondary: data.description, + }, + parameters + ); +} diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index 9080b6b27d4f..36aa7b8f0d18 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -1356,6 +1356,19 @@ function partParameterFields(options={}) { display_name: choice, }); }); + } else if (response.selectionlist) { + // Selection list - get choices from the API + inventreeGet(`{% url "api-selectionlist-list" %}${response.selectionlist}/`, {}, { + async: false, + success: function(data) { + data.choices.forEach(function(item) { + choices.push({ + value: item.value, + display_name: item.label, + }); + }); + } + }); } } }); @@ -1576,6 +1589,7 @@ function partParameterTemplateFields() { icon: 'fa-th-list', }, checkbox: {}, + selectionlist: {}, }; } From c03421a273e8e01706142081a27500ad267f4b70 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 17:55:42 +0200 Subject: [PATCH 06/41] Add selection option to PUI --- src/frontend/src/enums/ApiEndpoints.tsx | 2 ++ src/frontend/src/forms/PartForms.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 6ce43aa91079..f3d9d7a8b0fe 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -52,6 +52,8 @@ export enum ApiEndpoints { owner_list = 'user/owner/', content_type_list = 'contenttype/', icons = 'icons/', + selectionlist_list = 'selection/', + selectionlist_detail = 'selection/:id/', // Data import endpoints import_session_list = 'importer/session/', diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 0241f1207817..b1bd59e67e66 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -2,7 +2,10 @@ import { t } from '@lingui/macro'; import { IconPackages } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; +import { api } from '../App'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; +import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { apiUrl } from '../states/ApiState'; import { useGlobalSettingsState } from '../states/SettingsState'; /** @@ -184,6 +187,22 @@ export function usePartParameterFields({ setChoices([]); setFieldType('string'); } + } else if (record?.selectionlist) { + api + .get( + apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist) + ) + .then((res) => { + setChoices( + res.data.choices.map((item: any) => { + return { + value: item.value, + display_name: item.label + }; + }) + ); + setFieldType('choice'); + }); } else { setChoices([]); setFieldType('string'); From 397d51405692e8285b86e64fea914aaf26cb7ea2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 17:55:53 +0200 Subject: [PATCH 07/41] fix display --- src/frontend/src/forms/PartForms.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index b1bd59e67e66..837cecf53e69 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -177,7 +177,7 @@ export function usePartParameterFields({ setChoices( _choices.map((choice) => { return { - label: choice.trim(), + display_name: choice.trim(), value: choice.trim() }; }) From 723b3cac39d14549b49529ee0b9a5b5279aea922 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 18:16:32 +0200 Subject: [PATCH 08/41] add PUI admin entries --- src/frontend/src/components/render/Generic.tsx | 13 +++++++++++++ src/frontend/src/components/render/Instance.tsx | 6 ++++-- src/frontend/src/enums/ModelType.tsx | 3 ++- .../src/tables/part/PartParameterTemplateTable.tsx | 3 ++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index 3d04f990df6f..bf6d7ea076da 100644 --- a/src/frontend/src/components/render/Generic.tsx +++ b/src/frontend/src/components/render/Generic.tsx @@ -28,3 +28,16 @@ export function RenderImportSession({ }): ReactNode { return instance && ; } + +export function RenderSelectionList({ + instance +}: Readonly): ReactNode { + return ( + instance && ( + + ) + ); +} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index c2df42a7d3e6..7c955aabd0b8 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -19,7 +19,8 @@ import { import { RenderContentType, RenderImportSession, - RenderProjectCode + RenderProjectCode, + RenderSelectionList } from './Generic'; import { ModelInformationDict } from './ModelType'; import { @@ -92,7 +93,8 @@ const RendererLookup: EnumDictionary< [ModelType.reporttemplate]: RenderReportTemplate, [ModelType.labeltemplate]: RenderLabelTemplate, [ModelType.pluginconfig]: RenderPlugin, - [ModelType.contenttype]: RenderContentType + [ModelType.contenttype]: RenderContentType, + [ModelType.selectionlist]: RenderSelectionList }; export type RenderInstanceProps = { diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx index 15d36d2d3671..265047cbda3e 100644 --- a/src/frontend/src/enums/ModelType.tsx +++ b/src/frontend/src/enums/ModelType.tsx @@ -32,5 +32,6 @@ export enum ModelType { reporttemplate = 'reporttemplate', labeltemplate = 'labeltemplate', pluginconfig = 'pluginconfig', - contenttype = 'contenttype' + contenttype = 'contenttype', + selectionlist = 'selectionlist' } diff --git a/src/frontend/src/tables/part/PartParameterTemplateTable.tsx b/src/frontend/src/tables/part/PartParameterTemplateTable.tsx index f19c14dcc1ea..8f8b51f63e57 100644 --- a/src/frontend/src/tables/part/PartParameterTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartParameterTemplateTable.tsx @@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() { description: {}, units: {}, choices: {}, - checkbox: {} + checkbox: {}, + selectionlist: {} }; }, []); From e91140697828aa1070b81fb8e962ac450f9093ed Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 18:17:52 +0200 Subject: [PATCH 09/41] remove get_api_url --- src/backend/InvenTree/common/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index ad46e79134f8..97847e5697dc 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3605,7 +3605,3 @@ def __str__(self): if not self.active: return f'{self.label} (Inactive)' return self.label - - def get_api_url(self): - """Return the API URL associated with the SelectionListEntry model.""" - return reverse('api-selectionlistentry-list', kwargs={'pk': self.pk}) From a8257976aa8198c9f7fb619f5ebe7ba46db9cfe6 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 18:25:16 +0200 Subject: [PATCH 10/41] fix modeldict --- src/frontend/src/components/render/ModelType.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index 20b5fa1bb7fe..2ca20eb46e14 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -246,6 +246,11 @@ export const ModelInformationDict: ModelDict = { label: t`Content Type`, label_multiple: t`Content Types`, api_endpoint: ApiEndpoints.content_type_list + }, + selectionlist: { + label: t`Selection List`, + label_multiple: t`Selection Lists`, + api_endpoint: ApiEndpoints.selectionlist_list } }; From 92cf5a5ef2a4d17e2e884ea5960958691e919042 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 18:34:55 +0200 Subject: [PATCH 11/41] Add models for meta --- src/backend/InvenTree/common/tests.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index c5001fd635d8..cc13b0f4c641 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1725,6 +1725,16 @@ def test_api(self): self.assertEqual(response.data['label'], 'Test Entry') self.assertEqual(response.data['description'], 'Test Description') + def test_model_meta(self): + """Test model meta functions.""" + # Models str + self.assertEqual(str(self.list), 'Test List') + self.assertEqual(str(self.entry1), 'Test Entry') + self.assertEqual(str(self.entry2), 'Test Entry 2 (Inactive)') + + # API urls + self.assertEqual(self.list.get_api_url(), '/api/selection/') + def test_parameter(self): """Test the SelectionList parameter.""" self.assertEqual(self.list.get_choices(), ['test1']) From 5dc0e8943a16216b63f3d5653881a4b192eba134 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 19:06:25 +0200 Subject: [PATCH 12/41] Add test for inactive lists --- src/backend/InvenTree/common/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index cc13b0f4c641..9f57b0de84d6 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1702,6 +1702,7 @@ def setUp(self): description='Test Description 2', active=False, ) + self.list2 = SelectionList.objects.create(name='Test List 2', active=False) def test_api(self): """Test the SelectionList and SelctionListEntry API endpoints.""" @@ -1729,6 +1730,7 @@ def test_model_meta(self): """Test model meta functions.""" # Models str self.assertEqual(str(self.list), 'Test List') + self.assertEqual(str(self.list2), 'Test List 2 (Inactive)') self.assertEqual(str(self.entry1), 'Test Entry') self.assertEqual(str(self.entry2), 'Test Entry 2 (Inactive)') From 85c0568d04825c2c39970f916ea24b0c311d1aee Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 2 Sep 2024 23:57:27 +0200 Subject: [PATCH 13/41] Add locking and testing for locking --- src/backend/InvenTree/common/serializers.py | 14 ++++++++ src/backend/InvenTree/common/tests.py | 36 ++++++++++++++++----- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 13fca15d7609..d513afe29890 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -628,6 +628,13 @@ class Meta: model = common_models.SelectionListEntry fields = '__all__' + def validate(self, attrs): + """Ensure that the selection list is not locked.""" + ret = super().validate(attrs) + if self.instance.list.locked: + raise serializers.ValidationError({'list': _('Selection list is locked')}) + return ret + class SelectionListSerializer(InvenTreeModelSerializer): """Serializer for a selection list.""" @@ -652,3 +659,10 @@ class Meta: default = SelectionEntrySerializer(read_only=True, many=False) choices = SelectionEntrySerializer(source='entries', read_only=True, many=True) + + def validate(self, attrs): + """Ensure that the selection list is not locked.""" + ret = super().validate(attrs) + if self.instance.locked: + raise serializers.ValidationError({'locked': _('Selection list is locked')}) + return ret diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 9f57b0de84d6..b8773f7040d3 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1704,28 +1704,48 @@ def setUp(self): ) self.list2 = SelectionList.objects.create(name='Test List 2', active=False) + # Urls + self.list_url = reverse('api-selectionlist-detail', kwargs={'pk': self.list.pk}) + self.entry_url = reverse( + 'api-selectionlistentry-detail', + kwargs={'entrypk': self.entry1.pk, 'pk': self.list.pk}, + ) + def test_api(self): """Test the SelectionList and SelctionListEntry API endpoints.""" url = reverse('api-selectionlist-list') response = self.get(url, expected_code=200) - self.assertEqual(len(response.data), 1) + self.assertEqual(len(response.data), 2) - url = reverse('api-selectionlist-detail', kwargs={'pk': self.list.pk}) - response = self.get(url, expected_code=200) + response = self.get(self.list_url, expected_code=200) self.assertEqual(response.data['name'], 'Test List') self.assertEqual(len(response.data['choices']), 2) self.assertEqual(response.data['choices'][0]['value'], 'test1') self.assertEqual(response.data['choices'][0]['label'], 'Test Entry') - url = reverse( - 'api-selectionlistentry-detail', - kwargs={'entrypk': self.entry1.pk, 'pk': self.list.pk}, - ) - response = self.get(url, expected_code=200) + response = self.get(self.entry_url, expected_code=200) self.assertEqual(response.data['value'], 'test1') self.assertEqual(response.data['label'], 'Test Entry') self.assertEqual(response.data['description'], 'Test Description') + def test_api_locked(self): + """Test editing with locked/unlocked list.""" + # Lock list + self.list.locked = True + self.list.save() + response = self.patch(self.entry_url, {'label': 'New Label'}, expected_code=400) + self.assertIn('Selection list is locked', response.data['list']) + response = self.patch(self.list_url, {'name': 'New Name'}, expected_code=400) + self.assertIn('Selection list is locked', response.data['locked']) + + # Unlock the list + self.list.locked = False + self.list.save() + response = self.patch(self.entry_url, {'label': 'New Label'}, expected_code=200) + self.assertEqual(response.data['label'], 'New Label') + response = self.patch(self.list_url, {'name': 'New Name'}, expected_code=200) + self.assertEqual(response.data['name'], 'New Name') + def test_model_meta(self): """Test model meta functions.""" # Models str From 92e4afac7a735a7768ef6ac8c611c0bcde9b062c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Sep 2024 00:26:01 +0200 Subject: [PATCH 14/41] ignore unneeded section --- src/backend/InvenTree/common/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index b8773f7040d3..ff4bc8600282 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -438,7 +438,7 @@ def test_defaults(self): try: InvenTreeSetting.set_setting(key, value, change_user=self.user) - except Exception as exc: + except Exception as exc: # pragma: no cover print(f"test_defaults: Failed to set default value for setting '{key}'") raise exc From 3ef81b45ad896cc65fd55967171788734536168f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Sep 2024 00:59:40 +0200 Subject: [PATCH 15/41] Add PUI testing for adding parameter --- .../tests/settings/selectionList.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/frontend/tests/settings/selectionList.spec.ts diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts new file mode 100644 index 000000000000..7dc60476e69f --- /dev/null +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -0,0 +1,27 @@ +import { test } from '../baseFixtures'; +import { doQuickLogin } from '../login'; + +test('PUI - Admin - Parameter', async ({ page }) => { + await doQuickLogin(page, 'admin', 'inventree'); + await page.getByRole('button', { name: 'admin' }).click(); + await page.getByRole('menuitem', { name: 'Admin Center' }).click(); + + await page.getByRole('tab', { name: 'Part Parameters' }).click(); + await page.getByLabel('action-button-add-parameter-').click(); + await page.getByLabel('text-field-name').click(); + await page.getByLabel('text-field-name').fill('my custom parameter'); + await page.getByLabel('text-field-description').click(); + await page.getByLabel('text-field-description').fill('description'); + await page + .locator('div') + .filter({ hasText: /^Search\.\.\.$/ }) + .nth(2) + .click(); + await page + .getByRole('option', { name: 'some list List with some' }) + .locator('div') + .first() + .click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('cell', { name: 'my custom parameter' }).click(); +}); From ec1151b4a9eb2b4e2a5b4158bad089671b6bbaf2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Sep 2024 01:16:08 +0200 Subject: [PATCH 16/41] Add selectionList admin --- src/frontend/src/forms/CommonForms.tsx | 13 ++ .../Index/Settings/AdminCenter/Index.tsx | 8 +- .../AdminCenter/PartParameterPanel.tsx | 25 ++++ .../src/tables/part/SelectionListTable.tsx | 130 ++++++++++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx create mode 100644 src/frontend/src/tables/part/SelectionListTable.tsx diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 40c060b92d28..cc5a6984bbf2 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -46,3 +46,16 @@ export function extraLineItemFields(): ApiFormFieldSet { link: {} }; } + +export function selectionListFields(): ApiFormFieldSet { + return { + name: {}, + description: {}, + active: {}, + locked: {}, + source_plugin: {}, + source_string: {}, + default: {} + //choices: {}, + }; +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 7c0b51c8d735..cee562073b2d 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -60,6 +60,8 @@ const MachineManagementPanel = Loadable( lazy(() => import('./MachineManagementPanel')) ); +const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel'))); + const ErrorReportTable = Loadable( lazy(() => import('../../../../tables/settings/ErrorTable')) ); @@ -80,10 +82,6 @@ const CustomUnitsTable = Loadable( lazy(() => import('../../../../tables/settings/CustomUnitsTable')) ); -const PartParameterTemplateTable = Loadable( - lazy(() => import('../../../../tables/part/PartParameterTemplateTable')) -); - const PartCategoryTemplateTable = Loadable( lazy(() => import('../../../../tables/part/PartCategoryTemplateTable')) ); @@ -155,7 +153,7 @@ export default function AdminCenter() { name: 'part-parameters', label: t`Part Parameters`, icon: , - content: + content: }, { name: 'category-parameters', diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx new file mode 100644 index 000000000000..0cae3441acd8 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx @@ -0,0 +1,25 @@ +import { t } from '@lingui/macro'; +import { Accordion } from '@mantine/core'; + +import { StylishText } from '../../../../components/items/StylishText'; +import PartParameterTemplateTable from '../../../../tables/part/PartParameterTemplateTable'; +import SelectionListTable from '../../../../tables/part/SelectionListTable'; + +export default function PartParameterPanel() { + return ( + <> + + + + + + {t`Selection Lists`} + + + + + + + + ); +} diff --git a/src/frontend/src/tables/part/SelectionListTable.tsx b/src/frontend/src/tables/part/SelectionListTable.tsx new file mode 100644 index 000000000000..041209c430e4 --- /dev/null +++ b/src/frontend/src/tables/part/SelectionListTable.tsx @@ -0,0 +1,130 @@ +import { t } from '@lingui/macro'; +import { useCallback, useMemo, useState } from 'react'; + +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { UserRoles } from '../../enums/Roles'; +import { selectionListFields } from '../../forms/CommonForms'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { BooleanColumn } from '../ColumnRenderers'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; + +/** + * Table for displaying list of selectionlist items + */ +export default function SelectionListTable() { + const table = useTable('selectionlist'); + + const user = useUserState(); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'name', + sortable: true + }, + { + accessor: 'description', + sortable: true + }, + BooleanColumn({ + accessor: 'active' + }), + BooleanColumn({ + accessor: 'locked' + }), + { + accessor: 'source_plugin', + sortable: true + }, + { + accessor: 'source_string', + sortable: true + } + ]; + }, []); + + const newSelectionList = useCreateApiFormModal({ + url: ApiEndpoints.selectionlist_list, + title: t`Add Selection List`, + fields: selectionListFields(), + table: table + }); + + const [selectedSelectionList, setSelectedSelectionList] = useState< + number | undefined + >(undefined); + + const editSelectionList = useEditApiFormModal({ + url: ApiEndpoints.selectionlist_list, + pk: selectedSelectionList, + title: t`Edit Selection List`, + fields: selectionListFields(), + table: table + }); + + const deleteSelectionList = useDeleteApiFormModal({ + url: ApiEndpoints.selectionlist_list, + pk: selectedSelectionList, + title: t`Delete Selection List`, + table: table + }); + + const rowActions = useCallback( + (record: any): RowAction[] => { + return [ + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.admin), + onClick: () => { + setSelectedSelectionList(record.pk); + editSelectionList.open(); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.admin), + onClick: () => { + setSelectedSelectionList(record.pk); + deleteSelectionList.open(); + } + }) + ]; + }, + [user] + ); + + const tableActions = useMemo(() => { + return [ + newSelectionList.open()} + tooltip={t`Add Selection List`} + /> + ]; + }, []); + + return ( + <> + {newSelectionList.modal} + {editSelectionList.modal} + {deleteSelectionList.modal} + + + ); +} From 1bb38a007f9f80698865c86e9bdd35443b7b5e10 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Sep 2024 01:16:49 +0200 Subject: [PATCH 17/41] also allow creating entries --- src/backend/InvenTree/common/api.py | 2 +- src/backend/InvenTree/common/serializers.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 580e71107f25..abfe1aae4d72 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -775,7 +775,7 @@ def get_queryset(self): return get_icon_packs().values() -class SelectionListList(ListAPI): +class SelectionListList(ListCreateAPI): """List view for SelectionList objects.""" queryset = common.models.SelectionList.objects.all() diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index d513afe29890..8156b458e67d 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -631,7 +631,7 @@ class Meta: def validate(self, attrs): """Ensure that the selection list is not locked.""" ret = super().validate(attrs) - if self.instance.list.locked: + if self.instance and self.instance.list.locked: raise serializers.ValidationError({'list': _('Selection list is locked')}) return ret @@ -663,6 +663,6 @@ class Meta: def validate(self, attrs): """Ensure that the selection list is not locked.""" ret = super().validate(attrs) - if self.instance.locked: + if self.instance and self.instance.locked: raise serializers.ValidationError({'locked': _('Selection list is locked')}) return ret From 3f1399e40b24014e81234dbed4220c9a8615d9f7 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Sep 2024 01:28:20 +0200 Subject: [PATCH 18/41] extend tests --- src/frontend/tests/settings/selectionList.spec.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index 7dc60476e69f..cdca8693a44e 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -5,12 +5,19 @@ test('PUI - Admin - Parameter', async ({ page }) => { await doQuickLogin(page, 'admin', 'inventree'); await page.getByRole('button', { name: 'admin' }).click(); await page.getByRole('menuitem', { name: 'Admin Center' }).click(); - await page.getByRole('tab', { name: 'Part Parameters' }).click(); + + // Add selection list + await page.getByRole('button', { name: 'Selection Lists' }).click(); + await page.getByLabel('action-button-add-selection-').click(); + await page.getByLabel('text-field-name').fill('some list'); + await page.getByLabel('text-field-description').fill('Listdescription'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('cell', { name: 'some list' }).waitFor(); + + // Add parameter await page.getByLabel('action-button-add-parameter-').click(); - await page.getByLabel('text-field-name').click(); await page.getByLabel('text-field-name').fill('my custom parameter'); - await page.getByLabel('text-field-description').click(); await page.getByLabel('text-field-description').fill('description'); await page .locator('div') @@ -18,7 +25,7 @@ test('PUI - Admin - Parameter', async ({ page }) => { .nth(2) .click(); await page - .getByRole('option', { name: 'some list List with some' }) + .getByRole('option', { name: 'some list' }) .locator('div') .first() .click(); From 10947bc25345ce5fad48522ebeeb9ef2ef1a4670 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Sep 2024 23:33:28 +0200 Subject: [PATCH 19/41] force click --- src/frontend/tests/settings/selectionList.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index cdca8693a44e..15a429854ee1 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -16,7 +16,7 @@ test('PUI - Admin - Parameter', async ({ page }) => { await page.getByRole('cell', { name: 'some list' }).waitFor(); // Add parameter - await page.getByLabel('action-button-add-parameter-').click(); + await page.getByLabel('action-button-add-parameter-').click({ force: true }); await page.getByLabel('text-field-name').fill('my custom parameter'); await page.getByLabel('text-field-description').fill('description'); await page From f02104eecaca94c739ba6f01fafc30f2d5143c76 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 4 Sep 2024 00:07:43 +0200 Subject: [PATCH 20/41] and more testing --- .../tests/settings/selectionList.spec.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index 15a429854ee1..cedceff9f8c4 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -1,4 +1,5 @@ import { test } from '../baseFixtures'; +import { baseUrl } from '../defaults'; import { doQuickLogin } from '../login'; test('PUI - Admin - Parameter', async ({ page }) => { @@ -14,9 +15,10 @@ test('PUI - Admin - Parameter', async ({ page }) => { await page.getByLabel('text-field-description').fill('Listdescription'); await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('cell', { name: 'some list' }).waitFor(); + await page.waitForTimeout(200); // Add parameter - await page.getByLabel('action-button-add-parameter-').click({ force: true }); + await page.getByLabel('action-button-add-parameter-').click(); await page.getByLabel('text-field-name').fill('my custom parameter'); await page.getByLabel('text-field-description').fill('description'); await page @@ -31,4 +33,27 @@ test('PUI - Admin - Parameter', async ({ page }) => { .click(); await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('cell', { name: 'my custom parameter' }).click(); + + // Fill parameter + await page.goto(`${baseUrl}/part/104/parameters/`); + await page.getByLabel('Parameters').getByText('Parameters').waitFor(); + await page.getByLabel('action-button-add-parameter').click(); + await page.waitForTimeout(200); + await page.getByText('New Part Parameter').waitFor(); + // await page.getByText('Blue Square Table').waitFor(); + await page + .getByText('Template *Parameter') + .locator('div') + .filter({ hasText: /^Search\.\.\.$/ }) + .nth(2) + .click(); + await page + .getByText('Template *Parameter') + .locator('div') + .filter({ hasText: /^Search\.\.\.$/ }) + .locator('input') + .fill('my custom parameter'); + await page.getByRole('option', { name: 'my custom parameter' }).click(); + await page.getByLabel('choice-field-data').fill('2'); + await page.getByRole('button', { name: 'Submit' }).click(); }); From 61d9408dafd77597787a10d6ab0e40e42be2ca8f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 4 Sep 2024 00:48:27 +0200 Subject: [PATCH 21/41] adapt test? --- src/frontend/tests/settings/selectionList.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index cedceff9f8c4..051f96861e5b 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -18,7 +18,7 @@ test('PUI - Admin - Parameter', async ({ page }) => { await page.waitForTimeout(200); // Add parameter - await page.getByLabel('action-button-add-parameter-').click(); + await page.getByLabel('action-button-add-parameter').click(); await page.getByLabel('text-field-name').fill('my custom parameter'); await page.getByLabel('text-field-description').fill('description'); await page From 1e7df281c70d8bdb2f8bc5f013f7ac74ab56b72c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 4 Sep 2024 07:50:21 +0200 Subject: [PATCH 22/41] more assurance? --- src/frontend/tests/settings/selectionList.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index 051f96861e5b..526e34ecf7fc 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -18,6 +18,8 @@ test('PUI - Admin - Parameter', async ({ page }) => { await page.waitForTimeout(200); // Add parameter + await page.waitForLoadState('networkidle'); + await page.getByLabel('action-button-add-parameter').waitFor(); await page.getByLabel('action-button-add-parameter').click(); await page.getByLabel('text-field-name').fill('my custom parameter'); await page.getByLabel('text-field-description').fill('description'); @@ -37,6 +39,8 @@ test('PUI - Admin - Parameter', async ({ page }) => { // Fill parameter await page.goto(`${baseUrl}/part/104/parameters/`); await page.getByLabel('Parameters').getByText('Parameters').waitFor(); + await page.waitForLoadState('networkidle'); + await page.getByLabel('action-button-add-parameter').waitFor(); await page.getByLabel('action-button-add-parameter').click(); await page.waitForTimeout(200); await page.getByText('New Part Parameter').waitFor(); From b23f5a12186b7806812deeba4867604b5d3360b3 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 9 Sep 2024 01:30:30 +0200 Subject: [PATCH 23/41] make test more robust --- .../tests/settings/selectionList.spec.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index 526e34ecf7fc..a9ef4923363a 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -8,8 +8,41 @@ test('PUI - Admin - Parameter', async ({ page }) => { await page.getByRole('menuitem', { name: 'Admin Center' }).click(); await page.getByRole('tab', { name: 'Part Parameters' }).click(); - // Add selection list await page.getByRole('button', { name: 'Selection Lists' }).click(); + await page.waitForLoadState('networkidle'); + + // clean old data if exists + await page + .getByRole('cell', { name: 'some list' }) + .waitFor({ timeout: 200 }) + .then(async (cell) => { + await page + .getByRole('cell', { name: 'some list' }) + .locator('..') + .getByLabel('row-action-menu-') + .click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + }) + .catch(() => {}); + + // clean old data if exists + await page + .getByRole('cell', { name: 'my custom parameter' }) + .waitFor({ timeout: 200 }) + .then(async (cell) => { + await page + .getByRole('cell', { name: 'my custom parameter' }) + .locator('..') + .getByLabel('row-action-menu-') + .click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + }) + .catch(() => {}); + + // Add selection list + await page.getByLabel('action-button-add-selection-').waitFor(); await page.getByLabel('action-button-add-selection-').click(); await page.getByLabel('text-field-name').fill('some list'); await page.getByLabel('text-field-description').fill('Listdescription'); From 14f7f8f46816958043719b491266679cf9e332e9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 9 Sep 2024 01:31:15 +0200 Subject: [PATCH 24/41] more retries but shorter runs --- src/frontend/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 2a48cd028830..6cdd1fc755c5 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -3,9 +3,9 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', fullyParallel: true, - timeout: 90000, + timeout: 45000, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, + retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list', From f8c87e7d6c190cd2f0b1ae039485a1d69cc0dda8 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 9 Sep 2024 02:47:51 +0200 Subject: [PATCH 25/41] Update playwright.config.ts --- src/frontend/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 6cdd1fc755c5..7d69559b074d 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -3,10 +3,10 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', fullyParallel: true, - timeout: 45000, + timeout: 90000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 3 : undefined, reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list', /* Configure projects for major browsers */ From d08978df05111c23d2b8baf9544f7c6f877dd8fe Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 9 Sep 2024 07:58:15 +0200 Subject: [PATCH 26/41] Add docs --- docs/docs/part/parameter.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/docs/part/parameter.md b/docs/docs/part/parameter.md index 1e7028cc0bc7..76538bd3afc9 100644 --- a/docs/docs/part/parameter.md +++ b/docs/docs/part/parameter.md @@ -26,6 +26,7 @@ Parameter templates are used to define the different types of parameters which a | Units | Optional units field (*must be a valid [physical unit](#parameter-units)*) | | Choices | A comma-separated list of valid choices for parameter values linked to this template. | | Checkbox | If set, parameters linked to this template can only be assigned values *true* or *false* | +| Selection List | If set, parameters linked to this template can only be assigned values from the linked [selection list](#selection-lists) | ### Create Template @@ -105,3 +106,10 @@ Parameter sorting takes unit conversion into account, meaning that values provid {% with id="sort_by_param_units", url="part/part_sorting_units.png", description="Sort by Parameter Units" %} {% include 'img.html' %} {% endwith %} + +### Selection Lists + +Selection Lists can be used to add a large number of predefined values to a parameter template. This can be useful for parameters which must be selected from a large predefined list of values (e.g. a list of standardised colo codes). Choices on templates are limited to 5000 characters, selection lists can be used to overcome this limitation. + + +It is possible that plugins lock selection lists to ensure a known state. From 3a4f36cf68b0440c62a6e21f9bae41a1552672a3 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 9 Sep 2024 07:58:29 +0200 Subject: [PATCH 27/41] Add note regarding administration --- docs/docs/part/parameter.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/docs/part/parameter.md b/docs/docs/part/parameter.md index 76538bd3afc9..01058c1c877d 100644 --- a/docs/docs/part/parameter.md +++ b/docs/docs/part/parameter.md @@ -111,5 +111,7 @@ Parameter sorting takes unit conversion into account, meaning that values provid Selection Lists can be used to add a large number of predefined values to a parameter template. This can be useful for parameters which must be selected from a large predefined list of values (e.g. a list of standardised colo codes). Choices on templates are limited to 5000 characters, selection lists can be used to overcome this limitation. - It is possible that plugins lock selection lists to ensure a known state. + + +Administration of lists can be done through the Part Parameter section in the Admin Center or via the API. From 0a3e48ac030d22d7f08566e36931bf59c4a2fbbe Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 9 Sep 2024 08:55:15 +0200 Subject: [PATCH 28/41] Adapt to https://github.com/inventree/InvenTree/pull/8093 --- src/frontend/src/components/render/ModelType.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index 92ce93ef6492..cc298e478d16 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -191,8 +191,6 @@ export const ModelInformationDict: ModelDict = { api_endpoint: ApiEndpoints.content_type_list }, selectionlist: { - label: t`Selection List`, - label_multiple: t`Selection Lists`, api_endpoint: ApiEndpoints.selectionlist_list } }; @@ -265,6 +263,8 @@ export function getModelLabel(type: ModelType): string { return t`Plugin Configuration`; case ModelType.contenttype: return t`Content Type`; + case ModelType.selectionlist: + return t`Selection List`; default: return t`Unknown Model`; } @@ -338,6 +338,8 @@ export function getModelLabelMultiple(type: ModelType): string { return t`Plugin Configurations`; case ModelType.contenttype: return t`Content Types`; + case ModelType.selectionlist: + return t`Selection Lists`; default: return t`Unknown Models`; } From f756ab807868ebc47b5fc282b0a243e16c2d57a3 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 15 Sep 2024 21:58:44 +0200 Subject: [PATCH 29/41] make help text more descriptive --- src/backend/InvenTree/common/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 76bf2d1ae4a6..5c14900612c5 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3501,7 +3501,7 @@ class Meta: source_string = models.CharField( max_length=1000, verbose_name=_('Source String'), - help_text=_('Source string for this selection lists entries'), + help_text=_('Optional string identifying the source used for this list'), blank=True, ) From d0894b993647bdd9e4656c89e5f2e8dd3f665ed5 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 15 Sep 2024 22:07:21 +0200 Subject: [PATCH 30/41] fix migration --- .../0030_selectionlist_selectionlistentry_and_more.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py b/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py index 8343e67054e0..6b1447081185 100644 --- a/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py +++ b/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py @@ -73,7 +73,7 @@ class Migration(migrations.Migration): "source_string", models.CharField( blank=True, - help_text="Source string for this selection lists entries", + help_text="Optional string identifying the source used for this list", max_length=1000, verbose_name="Source String", ), From 88fe740a27a100a7fa8ca7bcd2fe107744f1e383 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 15 Sep 2024 22:26:43 +0200 Subject: [PATCH 31/41] remove unneeded UI entries --- src/frontend/src/forms/CommonForms.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index cc5a6984bbf2..b9714c23ad1a 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -54,8 +54,6 @@ export function selectionListFields(): ApiFormFieldSet { active: {}, locked: {}, source_plugin: {}, - source_string: {}, - default: {} - //choices: {}, + source_string: {} }; } From f5c2265034c75216b78dd57a4976995e9e1208c9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 16 Sep 2024 02:09:02 +0200 Subject: [PATCH 32/41] add lables and describtions to TableFields --- .../components/forms/fields/TableField.tsx | 106 ++++++++++-------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index 333ed40cac0f..cf90eea1899f 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -1,5 +1,5 @@ import { Trans, t } from '@lingui/macro'; -import { Container, Group, Table } from '@mantine/core'; +import { Container, Group, Input, Table } from '@mantine/core'; import { useCallback, useEffect, useMemo } from 'react'; import { FieldValues, UseControllerReturn } from 'react-hook-form'; @@ -44,6 +44,16 @@ export function TableField({ field.onChange(val); }; + const fieldDefinition = useMemo(() => { + return { + ...definition, + modelRenderer: undefined, + onValueChange: undefined, + adjustFilters: undefined, + read_only: undefined + }; + }, [definition]); + // Extract errors associated with the current row const rowErrors = useCallback( (idx: number) => { @@ -55,54 +65,56 @@ export function TableField({ ); return ( - - - - {definition.headers?.map((header) => { - return {header}; - })} - - - - {value.length > 0 ? ( - value.map((item: any, idx: number) => { - // Table fields require render function - if (!definition.modelRenderer) { - return ( - {t`modelRenderer entry required for tables`} - ); - } + +
+ + + {definition.headers?.map((header) => { + return {header}; + })} + + + + {value.length > 0 ? ( + value.map((item: any, idx: number) => { + // Table fields require render function + if (!definition.modelRenderer) { + return ( + {t`modelRenderer entry required for tables`} + ); + } - return definition.modelRenderer({ - item: item, - idx: idx, - rowErrors: rowErrors(idx), - control: control, - changeFn: onRowFieldChange, - removeFn: removeRow - }); - }) - ) : ( - - - + - - No entries available - - - - )} - -
+ + + No entries available + + + + )} + + + ); } From eed0b097788bb33373fc599273fbb99b0f1d81a9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 16 Sep 2024 02:13:03 +0200 Subject: [PATCH 33/41] factor out selectionList forms --- src/frontend/src/forms/CommonForms.tsx | 11 ----- .../src/forms/selectionListFields.tsx | 47 +++++++++++++++++++ .../src/tables/part/SelectionListTable.tsx | 2 +- 3 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 src/frontend/src/forms/selectionListFields.tsx diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index b9714c23ad1a..40c060b92d28 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -46,14 +46,3 @@ export function extraLineItemFields(): ApiFormFieldSet { link: {} }; } - -export function selectionListFields(): ApiFormFieldSet { - return { - name: {}, - description: {}, - active: {}, - locked: {}, - source_plugin: {}, - source_string: {} - }; -} diff --git a/src/frontend/src/forms/selectionListFields.tsx b/src/frontend/src/forms/selectionListFields.tsx new file mode 100644 index 000000000000..5d8e7a3a0459 --- /dev/null +++ b/src/frontend/src/forms/selectionListFields.tsx @@ -0,0 +1,47 @@ +import { t } from '@lingui/macro'; +import { Table } from '@mantine/core'; +import { useMemo } from 'react'; + +import RemoveRowButton from '../components/buttons/RemoveRowButton'; +import { StandaloneField } from '../components/forms/StandaloneField'; +import { + ApiFormFieldSet, + ApiFormFieldType +} from '../components/forms/fields/ApiFormField'; +import { TableFieldRowProps } from '../components/forms/fields/TableField'; +import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { ModelType } from '../enums/ModelType'; +import { apiUrl } from '../states/ApiState'; + +export function selectionRenderer(row: TableFieldRowProps) { + const item = row.item; + return ( + + + {item.value} + + {item.label} + {item.description} + {item.active ? 'Active' : 'Inactive'} + + ); +} + +export function selectionListFields(): ApiFormFieldSet { + return { + name: {}, + description: {}, + active: {}, + locked: {}, + source_plugin: {}, + source_string: {}, + choices: { + label: t`Entries`, + description: t`List of entries to choose from`, + field_type: 'table', + value: [], + headers: [t`Value`, t`Label`, t`Description`, t`Active`], + modelRenderer: selectionRenderer + } + }; +} diff --git a/src/frontend/src/tables/part/SelectionListTable.tsx b/src/frontend/src/tables/part/SelectionListTable.tsx index 041209c430e4..8654470ab04f 100644 --- a/src/frontend/src/tables/part/SelectionListTable.tsx +++ b/src/frontend/src/tables/part/SelectionListTable.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react'; import { AddItemButton } from '../../components/buttons/AddItemButton'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { UserRoles } from '../../enums/Roles'; -import { selectionListFields } from '../../forms/CommonForms'; +import { selectionListFields } from '../../forms/selectionListFields'; import { useCreateApiFormModal, useDeleteApiFormModal, From f5e09079aae02a6b444cf02e4f48b7aa2bc03879 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 16 Sep 2024 02:13:30 +0200 Subject: [PATCH 34/41] add key to button --- src/frontend/src/tables/part/SelectionListTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/src/tables/part/SelectionListTable.tsx b/src/frontend/src/tables/part/SelectionListTable.tsx index 8654470ab04f..00e02045851c 100644 --- a/src/frontend/src/tables/part/SelectionListTable.tsx +++ b/src/frontend/src/tables/part/SelectionListTable.tsx @@ -104,6 +104,7 @@ export default function SelectionListTable() { const tableActions = useMemo(() => { return [ newSelectionList.open()} tooltip={t`Add Selection List`} /> From c388b6ea456558e64e274925727adb469c5eb90a Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 16 Sep 2024 02:14:24 +0200 Subject: [PATCH 35/41] cleanup imports --- src/frontend/src/forms/selectionListFields.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/frontend/src/forms/selectionListFields.tsx b/src/frontend/src/forms/selectionListFields.tsx index 5d8e7a3a0459..d88bedfec80f 100644 --- a/src/frontend/src/forms/selectionListFields.tsx +++ b/src/frontend/src/forms/selectionListFields.tsx @@ -1,17 +1,8 @@ import { t } from '@lingui/macro'; import { Table } from '@mantine/core'; -import { useMemo } from 'react'; -import RemoveRowButton from '../components/buttons/RemoveRowButton'; -import { StandaloneField } from '../components/forms/StandaloneField'; -import { - ApiFormFieldSet, - ApiFormFieldType -} from '../components/forms/fields/ApiFormField'; +import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; import { TableFieldRowProps } from '../components/forms/fields/TableField'; -import { ApiEndpoints } from '../enums/ApiEndpoints'; -import { ModelType } from '../enums/ModelType'; -import { apiUrl } from '../states/ApiState'; export function selectionRenderer(row: TableFieldRowProps) { const item = row.item; From e890d3d591bfccca029e2a4e93139a1729364712 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 16 Sep 2024 09:03:20 +0200 Subject: [PATCH 36/41] add editable fields --- .../src/forms/selectionListFields.tsx | 89 +++++++++++++++++-- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/src/frontend/src/forms/selectionListFields.tsx b/src/frontend/src/forms/selectionListFields.tsx index d88bedfec80f..396b73611d1a 100644 --- a/src/frontend/src/forms/selectionListFields.tsx +++ b/src/frontend/src/forms/selectionListFields.tsx @@ -1,19 +1,88 @@ import { t } from '@lingui/macro'; import { Table } from '@mantine/core'; +import { useMemo } from 'react'; -import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; +import RemoveRowButton from '../components/buttons/RemoveRowButton'; +import { StandaloneField } from '../components/forms/StandaloneField'; +import { + ApiFormFieldSet, + ApiFormFieldType +} from '../components/forms/fields/ApiFormField'; import { TableFieldRowProps } from '../components/forms/fields/TableField'; -export function selectionRenderer(row: TableFieldRowProps) { - const item = row.item; +function BuildAllocateLineRow({ + props +}: Readonly<{ + props: TableFieldRowProps; +}>) { + const valueField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'string', + name: 'value', + required: true, + value: props.item.value, + onValueChange: (value: any) => { + props.changeFn(props.idx, 'value', value); + } + }; + }, [props]); + + const labelField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'string', + name: 'label', + required: true, + value: props.item.label, + onValueChange: (value: any) => { + props.changeFn(props.idx, 'label', value); + } + }; + }, [props]); + + const descriptionField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'string', + name: 'description', + required: true, + value: props.item.description, + onValueChange: (value: any) => { + props.changeFn(props.idx, 'description', value); + } + }; + }, [props]); + + const activeField: ApiFormFieldType = useMemo(() => { + return { + field_type: 'boolean', + name: 'active', + required: true, + value: props.item.active, + onValueChange: (value: any) => { + props.changeFn(props.idx, 'active', value); + } + }; + }, [props]); + return ( - + + + + + + + + + + + + + - {item.value} + props.removeFn(props.idx)} /> - {item.label} - {item.description} - {item.active ? 'Active' : 'Inactive'} ); } @@ -32,7 +101,9 @@ export function selectionListFields(): ApiFormFieldSet { field_type: 'table', value: [], headers: [t`Value`, t`Label`, t`Description`, t`Active`], - modelRenderer: selectionRenderer + modelRenderer: (row: TableFieldRowProps) => ( + + ) } }; } From c571a4290a879b010da20f599885eb3f9802896c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 16 Sep 2024 23:27:30 +0200 Subject: [PATCH 37/41] Add function to add row --- .../components/forms/fields/ApiFormField.tsx | 2 ++ .../components/forms/fields/TableField.tsx | 21 +++++++++++++++++++ .../src/forms/selectionListFields.tsx | 10 ++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 86539ce64c4c..d9ef0ef61d35 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -48,6 +48,7 @@ export type ApiFormAdjustFilterType = { * @param onValueChange : Callback function to call when the field value changes * @param adjustFilters : Callback function to adjust the filters for a related field before a query is made * @param adjustValue : Callback function to adjust the value of the field before it is sent to the API + * @param addRow : Callback function to add a new row to a table field */ export type ApiFormFieldType = { label?: string; @@ -91,6 +92,7 @@ export type ApiFormFieldType = { adjustValue?: (value: any) => any; onValueChange?: (value: any, record?: any) => void; adjustFilters?: (value: ApiFormAdjustFilterType) => any; + addRow?: () => any; headers?: string[]; depends_on?: string[]; }; diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index cf90eea1899f..f4b090e639be 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { FieldValues, UseControllerReturn } from 'react-hook-form'; import { InvenTreeIcon } from '../../../functions/icons'; +import { AddItemButton } from '../../buttons/AddItemButton'; import { StandaloneField } from '../StandaloneField'; import { ApiFormFieldType } from './ApiFormField'; @@ -113,6 +114,26 @@ export function TableField({ )} + {definition.addRow && ( + + + + { + if (definition.addRow === undefined) return; + const ret = definition.addRow(); + if (ret) { + const val = field.value; + val.push(ret); + field.onChange(val); + } + }} + /> + + + + )} ); diff --git a/src/frontend/src/forms/selectionListFields.tsx b/src/frontend/src/forms/selectionListFields.tsx index 396b73611d1a..702c7740eaca 100644 --- a/src/frontend/src/forms/selectionListFields.tsx +++ b/src/frontend/src/forms/selectionListFields.tsx @@ -103,7 +103,15 @@ export function selectionListFields(): ApiFormFieldSet { headers: [t`Value`, t`Label`, t`Description`, t`Active`], modelRenderer: (row: TableFieldRowProps) => ( - ) + ), + addRow: () => { + return { + value: '', + label: '', + description: '', + active: true + }; + } } }; } From 57cb954c720a54544da87a8de7324074ef3b9944 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 18 Sep 2024 00:35:29 +0200 Subject: [PATCH 38/41] fix render warning --- src/frontend/src/components/forms/fields/TableField.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index 2f29e0a4151d..e6e186ed586a 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -51,7 +51,8 @@ export function TableField({ modelRenderer: undefined, onValueChange: undefined, adjustFilters: undefined, - read_only: undefined + read_only: undefined, + addRow: undefined }; }, [definition]); From de5a8916b337158e0b0be0ae83544dc8c24223cb Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 18 Sep 2024 00:36:29 +0200 Subject: [PATCH 39/41] remove dead parameter --- src/frontend/tests/settings/selectionList.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts index a9ef4923363a..0837b0a198ee 100644 --- a/src/frontend/tests/settings/selectionList.spec.ts +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -77,7 +77,6 @@ test('PUI - Admin - Parameter', async ({ page }) => { await page.getByLabel('action-button-add-parameter').click(); await page.waitForTimeout(200); await page.getByText('New Part Parameter').waitFor(); - // await page.getByText('Blue Square Table').waitFor(); await page .getByText('Template *Parameter') .locator('div') From a041ad06bc4c7996265355c905cb759511ea234b Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 22 Oct 2024 23:24:41 +0200 Subject: [PATCH 40/41] fix migrations --- ...e.py => 0031_selectionlist_selectionlistentry_and_more.py} | 4 ++-- .../migrations/0131_partparametertemplate_selectionlist.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/backend/InvenTree/common/migrations/{0030_selectionlist_selectionlistentry_and_more.py => 0031_selectionlist_selectionlistentry_and_more.py} (98%) diff --git a/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py b/src/backend/InvenTree/common/migrations/0031_selectionlist_selectionlistentry_and_more.py similarity index 98% rename from src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py rename to src/backend/InvenTree/common/migrations/0031_selectionlist_selectionlistentry_and_more.py index 6b1447081185..6d393fbf423c 100644 --- a/src/backend/InvenTree/common/migrations/0030_selectionlist_selectionlistentry_and_more.py +++ b/src/backend/InvenTree/common/migrations/0031_selectionlist_selectionlistentry_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-01 22:13 +# Generated by Django 4.2.15 on 2024-10-22 21:19 import django.db.models.deletion from django.db import migrations, models @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ("plugin", "0009_alter_pluginconfig_key"), - ("common", "0029_inventreecustomuserstatemodel"), + ("common", "0030_barcodescanresult"), ] operations = [ diff --git a/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py b/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py index 11bd35091a89..6d9306397719 100644 --- a/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py +++ b/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-01 22:13 +# Generated by Django 4.2.15 on 2024-10-22 21:19 import django.db.models.deletion from django.db import migrations, models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ("common", "0030_selectionlist_selectionlistentry_and_more"), + ("common", "0031_selectionlist_selectionlistentry_and_more"), ("part", "0130_alter_parttesttemplate_part"), ] From d8b83ba3fce6057d6c96043ba35e80a2a0cccfea Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 5 Nov 2024 00:37:26 +0100 Subject: [PATCH 41/41] fix migrations --- ...ectionlist_selectionlistentry_and_more.py} | 131 +++++++++--------- ...131_partparametertemplate_selectionlist.py | 21 ++- 2 files changed, 75 insertions(+), 77 deletions(-) rename src/backend/InvenTree/common/migrations/{0031_selectionlist_selectionlistentry_and_more.py => 0032_selectionlist_selectionlistentry_and_more.py} (50%) diff --git a/src/backend/InvenTree/common/migrations/0031_selectionlist_selectionlistentry_and_more.py b/src/backend/InvenTree/common/migrations/0032_selectionlist_selectionlistentry_and_more.py similarity index 50% rename from src/backend/InvenTree/common/migrations/0031_selectionlist_selectionlistentry_and_more.py rename to src/backend/InvenTree/common/migrations/0032_selectionlist_selectionlistentry_and_more.py index 6d393fbf423c..1b258bdc200c 100644 --- a/src/backend/InvenTree/common/migrations/0031_selectionlist_selectionlistentry_and_more.py +++ b/src/backend/InvenTree/common/migrations/0032_selectionlist_selectionlistentry_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-10-22 21:19 +# Generated by Django 4.2.16 on 2024-11-04 23:35 import django.db.models.deletion from django.db import migrations, models @@ -7,184 +7,183 @@ class Migration(migrations.Migration): - dependencies = [ - ("plugin", "0009_alter_pluginconfig_key"), - ("common", "0030_barcodescanresult"), + ('plugin', '0009_alter_pluginconfig_key'), + ('common', '0031_auto_20241026_0024'), ] operations = [ migrations.CreateModel( - name="SelectionList", + name='SelectionList', fields=[ ( - "id", + 'id', models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name="ID", + verbose_name='ID', ), ), ( - "metadata", + 'metadata', models.JSONField( blank=True, - help_text="JSON metadata field, for use by external plugins", + help_text='JSON metadata field, for use by external plugins', null=True, - verbose_name="Plugin Metadata", + verbose_name='Plugin Metadata', ), ), ( - "name", + 'name', models.CharField( - help_text="Name of the selection list", + help_text='Name of the selection list', max_length=100, unique=True, - verbose_name="Name", + verbose_name='Name', ), ), ( - "description", + 'description', models.CharField( blank=True, - help_text="Description of the selection list", + help_text='Description of the selection list', max_length=250, - verbose_name="Description", + verbose_name='Description', ), ), ( - "locked", + 'locked', models.BooleanField( default=False, - help_text="Is this selection list locked?", - verbose_name="Locked", + help_text='Is this selection list locked?', + verbose_name='Locked', ), ), ( - "active", + 'active', models.BooleanField( default=True, - help_text="Can this selection list be used?", - verbose_name="Active", + help_text='Can this selection list be used?', + verbose_name='Active', ), ), ( - "source_string", + 'source_string', models.CharField( blank=True, - help_text="Optional string identifying the source used for this list", + help_text='Optional string identifying the source used for this list', max_length=1000, - verbose_name="Source String", + verbose_name='Source String', ), ), ( - "created", + 'created', models.DateTimeField( auto_now_add=True, - help_text="Date and time that the selection list was created", - verbose_name="Created", + help_text='Date and time that the selection list was created', + verbose_name='Created', ), ), ( - "last_updated", + 'last_updated', models.DateTimeField( auto_now=True, - help_text="Date and time that the selection list was last updated", - verbose_name="Last Updated", + help_text='Date and time that the selection list was last updated', + verbose_name='Last Updated', ), ), ], options={ - "verbose_name": "Selection List", - "verbose_name_plural": "Selection Lists", + 'verbose_name': 'Selection List', + 'verbose_name_plural': 'Selection Lists', }, bases=(InvenTree.models.PluginValidationMixin, models.Model), ), migrations.CreateModel( - name="SelectionListEntry", + name='SelectionListEntry', fields=[ ( - "id", + 'id', models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name="ID", + verbose_name='ID', ), ), ( - "value", + 'value', models.CharField( - help_text="Value of the selection list entry", + help_text='Value of the selection list entry', max_length=255, - verbose_name="Value", + verbose_name='Value', ), ), ( - "label", + 'label', models.CharField( - help_text="Label for the selection list entry", + help_text='Label for the selection list entry', max_length=255, - verbose_name="Label", + verbose_name='Label', ), ), ( - "description", + 'description', models.CharField( blank=True, - help_text="Description of the selection list entry", + help_text='Description of the selection list entry', max_length=250, - verbose_name="Description", + verbose_name='Description', ), ), ( - "active", + 'active', models.BooleanField( default=True, - help_text="Is this selection list entry active?", - verbose_name="Active", + help_text='Is this selection list entry active?', + verbose_name='Active', ), ), ( - "list", + 'list', models.ForeignKey( - help_text="Selection list to which this entry belongs", + help_text='Selection list to which this entry belongs', on_delete=django.db.models.deletion.CASCADE, - related_name="entries", - to="common.selectionlist", - verbose_name="Selection List", + related_name='entries', + to='common.selectionlist', + verbose_name='Selection List', ), ), ], options={ - "verbose_name": "Selection List Entry", - "verbose_name_plural": "Selection List Entries", - "unique_together": {("list", "value")}, + 'verbose_name': 'Selection List Entry', + 'verbose_name_plural': 'Selection List Entries', + 'unique_together': {('list', 'value')}, }, ), migrations.AddField( - model_name="selectionlist", - name="default", + model_name='selectionlist', + name='default', field=models.ForeignKey( blank=True, - help_text="Default entry for this selection list", + help_text='Default entry for this selection list', null=True, on_delete=django.db.models.deletion.SET_NULL, - to="common.selectionlistentry", - verbose_name="Default Entry", + to='common.selectionlistentry', + verbose_name='Default Entry', ), ), migrations.AddField( - model_name="selectionlist", - name="source_plugin", + model_name='selectionlist', + name='source_plugin', field=models.ForeignKey( blank=True, - help_text="Plugin which provides the selection list", + help_text='Plugin which provides the selection list', null=True, on_delete=django.db.models.deletion.SET_NULL, - to="plugin.pluginconfig", - verbose_name="Source Plugin", + to='plugin.pluginconfig', + verbose_name='Source Plugin', ), ), ] diff --git a/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py b/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py index 6d9306397719..a3d4b7f34244 100644 --- a/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py +++ b/src/backend/InvenTree/part/migrations/0131_partparametertemplate_selectionlist.py @@ -1,28 +1,27 @@ -# Generated by Django 4.2.15 on 2024-10-22 21:19 +# Generated by Django 4.2.16 on 2024-11-04 23:35 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ("common", "0031_selectionlist_selectionlistentry_and_more"), - ("part", "0130_alter_parttesttemplate_part"), + ('common', '0032_selectionlist_selectionlistentry_and_more'), + ('part', '0130_alter_parttesttemplate_part'), ] operations = [ migrations.AddField( - model_name="partparametertemplate", - name="selectionlist", + model_name='partparametertemplate', + name='selectionlist', field=models.ForeignKey( blank=True, - help_text="Selection list for this parameter", + help_text='Selection list for this parameter', null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="parameter_templates", - to="common.selectionlist", - verbose_name="Selection List", + related_name='parameter_templates', + to='common.selectionlist', + verbose_name='Selection List', ), - ), + ) ]