diff --git a/docs/docs/part/parameter.md b/docs/docs/part/parameter.md index 1e7028cc0bc..01058c1c877 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,12 @@ 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. + + +Administration of lists can be done through the Part Parameter section in the Admin Center or via the API. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index e247bbfcb8b..be08693782b 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 = 260 +INVENTREE_API_VERSION = 261 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v261 - 2024-09-26 : https://github.com/inventree/InvenTree/pull/8054 + - Adds "SelectionList" and "SelectionListEntry" API endpoints + v260 - 2024-09-26 : https://github.com/inventree/InvenTree/pull/8190 - Adds facility for server-side context data to be passed to client-side plugins diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index e257c31a2f2..8e0d98a9bbb 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -808,6 +808,78 @@ def get_queryset(self): return get_icon_packs().values() +class SelectionListList(ListCreateAPI): + """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( @@ -1016,6 +1088,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 00000000000..6b144708118 --- /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="Optional string identifying the source used for this list", + 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 ac76343f39c..39a165518e3 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3460,6 +3460,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=_('Optional string identifying the source used for this list'), + 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 + + @staticmethod + def get_api_url(): + """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 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 + + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 991254a1e29..398e068d475 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -633,3 +633,52 @@ 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__' + + def validate(self, attrs): + """Ensure that the selection list is not locked.""" + ret = super().validate(attrs) + if self.instance and self.instance.list.locked: + raise serializers.ValidationError({'list': _('Selection list is locked')}) + return ret + + +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) + + def validate(self, attrs): + """Ensure that the selection list is not locked.""" + ret = super().validate(attrs) + if self.instance and 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 6990fc9c18c..99afedd8618 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 @@ -40,6 +40,8 @@ NotificationEntry, NotificationMessage, ProjectCode, + SelectionList, + SelectionListEntry, WebhookEndpoint, WebhookMessage, ) @@ -429,7 +431,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 @@ -1676,3 +1678,115 @@ 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.""" + + fixtures = ['category', 'part', 'location', 'params', 'test_templates'] + + 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, + ) + 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), 2) + + 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') + + 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 + 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)') + + # 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']) + 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) 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 00000000000..11bd35091a8 --- /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 bfc499d6117..f7b220d402d 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3672,6 +3672,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: @@ -3753,6 +3754,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 [] @@ -3793,6 +3797,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, diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index ffc935abb38..97339570d6e 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -316,7 +316,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, diff --git a/src/backend/InvenTree/templates/js/translated/model_renderers.js b/src/backend/InvenTree/templates/js/translated/model_renderers.js index 67eb4186e79..6f4201eec5a 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 a065aad924a..8aba569efbe 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: {}, }; } diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index d4c54b58535..896514989ce 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -347,6 +347,8 @@ def get_ruleset_ignore(): 'common_webhookendpoint', 'common_webhookmessage', 'common_inventreecustomuserstatemodel', + 'common_selectionlistentry', + 'common_selectionlist', 'users_owner', # Third-party tables 'error_report_error', diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index f42af97995c..79a54c20b58 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -5,8 +5,8 @@ export default defineConfig({ fullyParallel: true, timeout: 90000, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 2 : undefined, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 3 : undefined, reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list', /* Configure projects for major browsers */ diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 2567b7055ba..70f8cefe6b8 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 83841eef318..e6e186ed586 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -1,9 +1,10 @@ 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'; import { InvenTreeIcon } from '../../../functions/icons'; +import { AddItemButton } from '../../buttons/AddItemButton'; import { StandaloneField } from '../StandaloneField'; import { ApiFormFieldType } from './ApiFormField'; @@ -44,6 +45,17 @@ export function TableField({ field.onChange(val); }; + const fieldDefinition = useMemo(() => { + return { + ...definition, + modelRenderer: undefined, + onValueChange: undefined, + adjustFilters: undefined, + read_only: undefined, + addRow: undefined + }; + }, [definition]); + // Extract errors associated with the current row const rowErrors = useCallback( (idx: number) => { @@ -55,54 +67,76 @@ 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 + + + + )} + + {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/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index 3d04f990df6..bf6d7ea076d 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 9f9c4b1c766..8ace8793423 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/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index 38118dc10f0..d8a5f8c95d1 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -252,6 +252,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 } }; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index b0bae1fd871..78f537a83d0 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -49,6 +49,8 @@ export enum ApiEndpoints { owner_list = 'user/owner/', content_type_list = 'contenttype/', icons = 'icons/', + selectionlist_list = 'selection/', + selectionlist_detail = 'selection/:id/', // Barcode API endpoints barcode = 'barcode/', diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx index 15d36d2d367..265047cbda3 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/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 7146695231b..4eb9238b363 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'; /** @@ -174,7 +177,7 @@ export function usePartParameterFields({ setChoices( _choices.map((choice) => { return { - label: choice.trim(), + display_name: choice.trim(), value: choice.trim() }; }) @@ -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'); diff --git a/src/frontend/src/forms/selectionListFields.tsx b/src/frontend/src/forms/selectionListFields.tsx new file mode 100644 index 00000000000..702c7740eac --- /dev/null +++ b/src/frontend/src/forms/selectionListFields.tsx @@ -0,0 +1,117 @@ +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'; + +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 ( + + + + + + + + + + + + + + + props.removeFn(props.idx)} /> + + + ); +} + +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: (row: TableFieldRowProps) => ( + + ), + addRow: () => { + return { + value: '', + label: '', + description: '', + active: true + }; + } + } + }; +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index be2aeb142a5..683223c136e 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -65,6 +65,8 @@ const MachineManagementPanel = Loadable( lazy(() => import('./MachineManagementPanel')) ); +const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel'))); + const ErrorReportTable = Loadable( lazy(() => import('../../../../tables/settings/ErrorTable')) ); @@ -85,6 +87,10 @@ const CustomStateTable = Loadable( lazy(() => import('../../../../tables/settings/CustomStateTable')) ); +const CustomUnitsTable = Loadable( + lazy(() => import('../../../../tables/settings/CustomUnitsTable')) +); + const PartParameterTemplateTable = Loadable( lazy(() => import('../../../../tables/part/PartParameterTemplateTable')) ); @@ -168,7 +174,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 00000000000..0cae3441acd --- /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/PartParameterTemplateTable.tsx b/src/frontend/src/tables/part/PartParameterTemplateTable.tsx index f19c14dcc1e..8f8b51f63e5 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: {} }; }, []); diff --git a/src/frontend/src/tables/part/SelectionListTable.tsx b/src/frontend/src/tables/part/SelectionListTable.tsx new file mode 100644 index 00000000000..00e02045851 --- /dev/null +++ b/src/frontend/src/tables/part/SelectionListTable.tsx @@ -0,0 +1,131 @@ +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/selectionListFields'; +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} + + + ); +} diff --git a/src/frontend/tests/settings/selectionList.spec.ts b/src/frontend/tests/settings/selectionList.spec.ts new file mode 100644 index 00000000000..0837b0a198e --- /dev/null +++ b/src/frontend/tests/settings/selectionList.spec.ts @@ -0,0 +1,95 @@ +import { test } from '../baseFixtures'; +import { baseUrl } from '../defaults'; +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.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'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('cell', { name: 'some list' }).waitFor(); + 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'); + await page + .locator('div') + .filter({ hasText: /^Search\.\.\.$/ }) + .nth(2) + .click(); + await page + .getByRole('option', { name: 'some list' }) + .locator('div') + .first() + .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.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(); + 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(); +});