mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-01 21:16:46 +00:00
Add SelectionList concept (#8054)
* Add SelectionList model, APIs and simple tests * Add managment entries * Add field to serializer * add more tests for parameters * Add support for SelectionList to CUI * Add selection option to PUI * fix display * add PUI admin entries * remove get_api_url * fix modeldict * Add models for meta * Add test for inactive lists * Add locking and testing for locking * ignore unneeded section * Add PUI testing for adding parameter * Add selectionList admin * also allow creating entries * extend tests * force click * and more testing * adapt test? * more assurance? * make test more robust * more retries but shorter runs * Update playwright.config.ts * Add docs * Add note regarding administration * Adapt to https://github.com/inventree/InvenTree/pull/8093 * make help text more descriptive * fix migration * remove unneeded UI entries * add lables and describtions to TableFields * factor out selectionList forms * add key to button * cleanup imports * add editable fields * Add function to add row * fix render warning * remove dead parameter * fix migrations * fix migrations * fix format * autofix * fix migrations * fix create / update loop * fix addition of empty lists * extend tests * adjust changelog entry * fix updating loop * update test name * merge migrations * simplify request * - Add entry count to list - Move parameter table to default accordion * fix test * fix test clearing section --------- Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
parent
20fb1250f8
commit
af39189e7e
@ -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)*) |
|
| 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. |
|
| 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* |
|
| 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
|
### 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" %}
|
{% with id="sort_by_param_units", url="part/part_sorting_units.png", description="Sort by Parameter Units" %}
|
||||||
{% include 'img.html' %}
|
{% include 'img.html' %}
|
||||||
{% endwith %}
|
{% 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.
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 285
|
INVENTREE_API_VERSION = 286
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v286 - 2024-11-26 : https://github.com/inventree/InvenTree/pull/8054
|
||||||
|
- Adds "SelectionList" and "SelectionListEntry" API endpoints
|
||||||
|
|
||||||
v285 - 2024-11-25 : https://github.com/inventree/InvenTree/pull/8559
|
v285 - 2024-11-25 : https://github.com/inventree/InvenTree/pull/8559
|
||||||
- Adds better description for registration endpoints
|
- Adds better description for registration endpoints
|
||||||
|
|
||||||
|
@ -808,6 +808,82 @@ class IconList(ListAPI):
|
|||||||
return get_icon_packs().values()
|
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]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Override the queryset method to include entry count."""
|
||||||
|
return self.serializer_class.annotate_queryset(super().get_queryset())
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
'<int:pk>/',
|
||||||
|
include([
|
||||||
|
# Entries
|
||||||
|
path(
|
||||||
|
'entry/',
|
||||||
|
include([
|
||||||
|
path(
|
||||||
|
'<int:entrypk>/',
|
||||||
|
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 = [
|
settings_api_urls = [
|
||||||
# User settings
|
# User settings
|
||||||
path(
|
path(
|
||||||
@ -1016,6 +1092,8 @@ common_api_urls = [
|
|||||||
),
|
),
|
||||||
# Icons
|
# Icons
|
||||||
path('icons/', IconList.as_view(), name='api-icon-list'),
|
path('icons/', IconList.as_view(), name='api-icon-list'),
|
||||||
|
# Selection lists
|
||||||
|
path('selection/', include(selection_urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
admin_api_urls = [
|
admin_api_urls = [
|
||||||
|
@ -0,0 +1,191 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-24 12:41
|
||||||
|
|
||||||
|
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', '0031_auto_20241026_0024'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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(
|
||||||
|
blank=True,
|
||||||
|
help_text='Selection list to which this entry belongs',
|
||||||
|
null=True,
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -3508,6 +3508,169 @@ class InvenTreeCustomUserStateModel(models.Model):
|
|||||||
return super().clean()
|
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 = self.entries.filter(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,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
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):
|
class BarcodeScanResult(InvenTree.models.InvenTreeModel):
|
||||||
"""Model for storing barcode scans results."""
|
"""Model for storing barcode scans results."""
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"""JSON serializers for common components."""
|
"""JSON serializers for common components."""
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import OuterRef, Subquery
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -638,3 +638,123 @@ class IconPackageSerializer(serializers.Serializer):
|
|||||||
prefix = serializers.CharField()
|
prefix = serializers.CharField()
|
||||||
fonts = serializers.DictField(child=serializers.CharField())
|
fonts = serializers.DictField(child=serializers.CharField())
|
||||||
icons = serializers.DictField(child=IconSerializer())
|
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."""
|
||||||
|
|
||||||
|
_choices_validated: dict = {}
|
||||||
|
|
||||||
|
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',
|
||||||
|
'entry_count',
|
||||||
|
]
|
||||||
|
|
||||||
|
default = SelectionEntrySerializer(read_only=True, many=False)
|
||||||
|
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
|
||||||
|
entry_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def annotate_queryset(queryset):
|
||||||
|
"""Add count of entries for each selection list."""
|
||||||
|
return queryset.annotate(entry_count=Count('entries'))
|
||||||
|
|
||||||
|
def is_valid(self, *, raise_exception=False):
|
||||||
|
"""Validate the selection list. Choices are validated separately."""
|
||||||
|
choices = (
|
||||||
|
self.initial_data.pop('choices')
|
||||||
|
if self.initial_data.get('choices') is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate the choices
|
||||||
|
_choices_validated = []
|
||||||
|
db_entries = (
|
||||||
|
{a.id: a for a in self.instance.entries.all()} if self.instance else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
for choice in choices:
|
||||||
|
current_inst = db_entries.get(choice.get('id'))
|
||||||
|
serializer = SelectionEntrySerializer(
|
||||||
|
instance=current_inst,
|
||||||
|
data={'list': current_inst.list.pk if current_inst else None, **choice},
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=raise_exception)
|
||||||
|
_choices_validated.append({
|
||||||
|
**serializer.validated_data,
|
||||||
|
'id': choice.get('id'),
|
||||||
|
})
|
||||||
|
self._choices_validated = _choices_validated
|
||||||
|
|
||||||
|
return super().is_valid(raise_exception=raise_exception)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Create a new selection list. Save the choices separately."""
|
||||||
|
list_entry = common_models.SelectionList.objects.create(**validated_data)
|
||||||
|
for choice_data in self._choices_validated:
|
||||||
|
common_models.SelectionListEntry.objects.create(**{
|
||||||
|
**choice_data,
|
||||||
|
'list': list_entry,
|
||||||
|
})
|
||||||
|
return list_entry
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Update an existing selection list. Save the choices separately."""
|
||||||
|
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
|
||||||
|
exsising_ids = {a.get('id') for a in self._choices_validated}
|
||||||
|
|
||||||
|
# Perform creations and updates.
|
||||||
|
ret = []
|
||||||
|
for data in self._choices_validated:
|
||||||
|
list_inst = data.get('list', None)
|
||||||
|
inst = inst_mapping.get(data.get('id'))
|
||||||
|
if inst is None:
|
||||||
|
if list_inst is None:
|
||||||
|
data['list'] = instance
|
||||||
|
ret.append(SelectionEntrySerializer().create(data))
|
||||||
|
else:
|
||||||
|
ret.append(SelectionEntrySerializer().update(inst, data))
|
||||||
|
|
||||||
|
# Perform deletions.
|
||||||
|
for entry_id in inst_mapping.keys() - exsising_ids:
|
||||||
|
inst_mapping[entry_id].delete()
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -29,7 +29,7 @@ from InvenTree.unit_test import (
|
|||||||
InvenTreeTestCase,
|
InvenTreeTestCase,
|
||||||
PluginMixin,
|
PluginMixin,
|
||||||
)
|
)
|
||||||
from part.models import Part
|
from part.models import Part, PartParameterTemplate
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting
|
from plugin.models import NotificationUserSetting
|
||||||
|
|
||||||
@ -45,6 +45,8 @@ from .models import (
|
|||||||
NotificationEntry,
|
NotificationEntry,
|
||||||
NotificationMessage,
|
NotificationMessage,
|
||||||
ProjectCode,
|
ProjectCode,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListEntry,
|
||||||
WebhookEndpoint,
|
WebhookEndpoint,
|
||||||
WebhookMessage,
|
WebhookMessage,
|
||||||
)
|
)
|
||||||
@ -434,7 +436,7 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
InvenTreeSetting.set_setting(key, value, change_user=self.user)
|
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}'")
|
print(f"test_defaults: Failed to set default value for setting '{key}'")
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
@ -1683,6 +1685,161 @@ class CustomStatusTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_update(self):
|
||||||
|
"""Test adding and editing via the SelectionList."""
|
||||||
|
# Test adding a new list via the API
|
||||||
|
response = self.post(
|
||||||
|
reverse('api-selectionlist-list'),
|
||||||
|
{
|
||||||
|
'name': 'New List',
|
||||||
|
'active': True,
|
||||||
|
'choices': [{'value': '1', 'label': 'Test Entry'}],
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
list_pk = response.data['pk']
|
||||||
|
self.assertEqual(response.data['name'], 'New List')
|
||||||
|
self.assertTrue(response.data['active'])
|
||||||
|
self.assertEqual(len(response.data['choices']), 1)
|
||||||
|
self.assertEqual(response.data['choices'][0]['value'], '1')
|
||||||
|
|
||||||
|
# Test editing the list choices via the API (remove and add in same call)
|
||||||
|
response = self.patch(
|
||||||
|
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||||
|
{'choices': [{'value': '2', 'label': 'New Label'}]},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.data['name'], 'New List')
|
||||||
|
self.assertTrue(response.data['active'])
|
||||||
|
self.assertEqual(len(response.data['choices']), 1)
|
||||||
|
self.assertEqual(response.data['choices'][0]['value'], '2')
|
||||||
|
self.assertEqual(response.data['choices'][0]['label'], 'New Label')
|
||||||
|
entry_id = response.data['choices'][0]['id']
|
||||||
|
|
||||||
|
# Test changing an entry via list API
|
||||||
|
response = self.patch(
|
||||||
|
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||||
|
{'choices': [{'id': entry_id, 'value': '2', 'label': 'New Label Text'}]},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.data['name'], 'New List')
|
||||||
|
self.assertTrue(response.data['active'])
|
||||||
|
self.assertEqual(len(response.data['choices']), 1)
|
||||||
|
self.assertEqual(response.data['choices'][0]['value'], '2')
|
||||||
|
self.assertEqual(response.data['choices'][0]['label'], 'New Label Text')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class AdminTest(AdminTestCase):
|
class AdminTest(AdminTestCase):
|
||||||
"""Tests for the admin interface integration."""
|
"""Tests for the admin interface integration."""
|
||||||
|
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-24 12:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('common', '0032_selectionlist_selectionlistentry_and_more'),
|
||||||
|
('part', '0131_partrelated_note'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
@ -3724,6 +3724,7 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
description: Description of the parameter [string]
|
description: Description of the parameter [string]
|
||||||
checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
|
checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
|
||||||
choices: List of valid choices for the parameter [string]
|
choices: List of valid choices for the parameter [string]
|
||||||
|
selectionlist: SelectionList that should be used for choices [selectionlist]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -3805,6 +3806,9 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
|
|
||||||
def get_choices(self):
|
def get_choices(self):
|
||||||
"""Return a list of choices for this parameter template."""
|
"""Return a list of choices for this parameter template."""
|
||||||
|
if self.selectionlist:
|
||||||
|
return self.selectionlist.get_choices()
|
||||||
|
|
||||||
if not self.choices:
|
if not self.choices:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -3845,6 +3849,16 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
blank=True,
|
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(
|
@receiver(
|
||||||
post_save,
|
post_save,
|
||||||
|
@ -316,7 +316,16 @@ class PartParameterTemplateSerializer(
|
|||||||
"""Metaclass defining serializer fields."""
|
"""Metaclass defining serializer fields."""
|
||||||
|
|
||||||
model = PartParameterTemplate
|
model = PartParameterTemplate
|
||||||
fields = ['pk', 'name', 'units', 'description', 'parts', 'checkbox', 'choices']
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'name',
|
||||||
|
'units',
|
||||||
|
'description',
|
||||||
|
'parts',
|
||||||
|
'checkbox',
|
||||||
|
'choices',
|
||||||
|
'selectionlist',
|
||||||
|
]
|
||||||
|
|
||||||
parts = serializers.IntegerField(
|
parts = serializers.IntegerField(
|
||||||
read_only=True,
|
read_only=True,
|
||||||
|
@ -102,6 +102,8 @@ function getModelRenderer(model) {
|
|||||||
return renderReportTemplate;
|
return renderReportTemplate;
|
||||||
case 'pluginconfig':
|
case 'pluginconfig':
|
||||||
return renderPluginConfig;
|
return renderPluginConfig;
|
||||||
|
case 'selectionlist':
|
||||||
|
return renderSelectionList;
|
||||||
default:
|
default:
|
||||||
// Un-handled model type
|
// Un-handled model type
|
||||||
console.error(`Rendering not implemented for model '${model}'`);
|
console.error(`Rendering not implemented for model '${model}'`);
|
||||||
@ -589,3 +591,15 @@ function renderPluginConfig(data, parameters={}) {
|
|||||||
parameters
|
parameters
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render for "SelectionList" model
|
||||||
|
function renderSelectionList(data, parameters={}) {
|
||||||
|
|
||||||
|
return renderModel(
|
||||||
|
{
|
||||||
|
text: data.name,
|
||||||
|
textSecondary: data.description,
|
||||||
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1356,6 +1356,19 @@ function partParameterFields(options={}) {
|
|||||||
display_name: choice,
|
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',
|
icon: 'fa-th-list',
|
||||||
},
|
},
|
||||||
checkbox: {},
|
checkbox: {},
|
||||||
|
selectionlist: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -347,6 +347,8 @@ class RuleSet(models.Model):
|
|||||||
'common_webhookendpoint',
|
'common_webhookendpoint',
|
||||||
'common_webhookmessage',
|
'common_webhookmessage',
|
||||||
'common_inventreecustomuserstatemodel',
|
'common_inventreecustomuserstatemodel',
|
||||||
|
'common_selectionlistentry',
|
||||||
|
'common_selectionlist',
|
||||||
'users_owner',
|
'users_owner',
|
||||||
# Third-party tables
|
# Third-party tables
|
||||||
'error_report_error',
|
'error_report_error',
|
||||||
|
@ -5,8 +5,8 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
timeout: 90000,
|
timeout: 90000,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 1 : 0,
|
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',
|
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
|
@ -49,6 +49,7 @@ export type ApiFormAdjustFilterType = {
|
|||||||
* @param onValueChange : Callback function to call when the field value changes
|
* @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 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 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
|
||||||
* @param onKeyDown : Callback function to get which key was pressed in the form to handle submission on enter
|
* @param onKeyDown : Callback function to get which key was pressed in the form to handle submission on enter
|
||||||
*/
|
*/
|
||||||
export type ApiFormFieldType = {
|
export type ApiFormFieldType = {
|
||||||
@ -94,6 +95,7 @@ export type ApiFormFieldType = {
|
|||||||
adjustValue?: (value: any) => any;
|
adjustValue?: (value: any) => any;
|
||||||
onValueChange?: (value: any, record?: any) => void;
|
onValueChange?: (value: any, record?: any) => void;
|
||||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||||
|
addRow?: () => any;
|
||||||
headers?: string[];
|
headers?: string[];
|
||||||
depends_on?: string[];
|
depends_on?: string[];
|
||||||
};
|
};
|
||||||
|
@ -6,6 +6,7 @@ import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
|||||||
|
|
||||||
import { identifierString } from '../../../functions/conversion';
|
import { identifierString } from '../../../functions/conversion';
|
||||||
import { InvenTreeIcon } from '../../../functions/icons';
|
import { InvenTreeIcon } from '../../../functions/icons';
|
||||||
|
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||||
import { StandaloneField } from '../StandaloneField';
|
import { StandaloneField } from '../StandaloneField';
|
||||||
import type { ApiFormFieldType } from './ApiFormField';
|
import type { ApiFormFieldType } from './ApiFormField';
|
||||||
|
|
||||||
@ -109,6 +110,17 @@ export function TableField({
|
|||||||
field.onChange(val);
|
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
|
// Extract errors associated with the current row
|
||||||
const rowErrors: any = useCallback(
|
const rowErrors: any = useCallback(
|
||||||
(idx: number) => {
|
(idx: number) => {
|
||||||
@ -134,6 +146,7 @@ export function TableField({
|
|||||||
})}
|
})}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
|
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{value.length > 0 ? (
|
{value.length > 0 ? (
|
||||||
value.map((item: any, idx: number) => {
|
value.map((item: any, idx: number) => {
|
||||||
@ -170,6 +183,26 @@ export function TableField({
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
{definition.addRow && (
|
||||||
|
<Table.Tfoot>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={definition.headers?.length}>
|
||||||
|
<AddItemButton
|
||||||
|
tooltip={t`Add new row`}
|
||||||
|
onClick={() => {
|
||||||
|
if (definition.addRow === undefined) return;
|
||||||
|
const ret = definition.addRow();
|
||||||
|
if (ret) {
|
||||||
|
const val = field.value;
|
||||||
|
val.push(ret);
|
||||||
|
field.onChange(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tfoot>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -34,3 +34,16 @@ export function RenderImportSession({
|
|||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
return instance && <RenderInlineModel primary={instance.data_file} />;
|
return instance && <RenderInlineModel primary={instance.data_file} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenderSelectionList({
|
||||||
|
instance
|
||||||
|
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||||
|
return (
|
||||||
|
instance && (
|
||||||
|
<RenderInlineModel
|
||||||
|
primary={instance.name}
|
||||||
|
secondary={instance.description}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -20,7 +20,8 @@ import {
|
|||||||
RenderContentType,
|
RenderContentType,
|
||||||
RenderError,
|
RenderError,
|
||||||
RenderImportSession,
|
RenderImportSession,
|
||||||
RenderProjectCode
|
RenderProjectCode,
|
||||||
|
RenderSelectionList
|
||||||
} from './Generic';
|
} from './Generic';
|
||||||
import { ModelInformationDict } from './ModelType';
|
import { ModelInformationDict } from './ModelType';
|
||||||
import {
|
import {
|
||||||
@ -94,6 +95,7 @@ const RendererLookup: EnumDictionary<
|
|||||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||||
[ModelType.pluginconfig]: RenderPlugin,
|
[ModelType.pluginconfig]: RenderPlugin,
|
||||||
[ModelType.contenttype]: RenderContentType,
|
[ModelType.contenttype]: RenderContentType,
|
||||||
|
[ModelType.selectionlist]: RenderSelectionList,
|
||||||
[ModelType.error]: RenderError
|
[ModelType.error]: RenderError
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -286,6 +286,12 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
api_endpoint: ApiEndpoints.content_type_list,
|
api_endpoint: ApiEndpoints.content_type_list,
|
||||||
icon: 'list_details'
|
icon: 'list_details'
|
||||||
},
|
},
|
||||||
|
selectionlist: {
|
||||||
|
label: () => t`Selection List`,
|
||||||
|
label_multiple: () => t`Selection Lists`,
|
||||||
|
api_endpoint: ApiEndpoints.selectionlist_list,
|
||||||
|
icon: 'list_details'
|
||||||
|
},
|
||||||
error: {
|
error: {
|
||||||
label: () => t`Error`,
|
label: () => t`Error`,
|
||||||
label_multiple: () => t`Errors`,
|
label_multiple: () => t`Errors`,
|
||||||
|
@ -49,6 +49,8 @@ export enum ApiEndpoints {
|
|||||||
owner_list = 'user/owner/',
|
owner_list = 'user/owner/',
|
||||||
content_type_list = 'contenttype/',
|
content_type_list = 'contenttype/',
|
||||||
icons = 'icons/',
|
icons = 'icons/',
|
||||||
|
selectionlist_list = 'selection/',
|
||||||
|
selectionlist_detail = 'selection/:id/',
|
||||||
|
|
||||||
// Barcode API endpoints
|
// Barcode API endpoints
|
||||||
barcode = 'barcode/',
|
barcode = 'barcode/',
|
||||||
|
@ -33,5 +33,6 @@ export enum ModelType {
|
|||||||
labeltemplate = 'labeltemplate',
|
labeltemplate = 'labeltemplate',
|
||||||
pluginconfig = 'pluginconfig',
|
pluginconfig = 'pluginconfig',
|
||||||
contenttype = 'contenttype',
|
contenttype = 'contenttype',
|
||||||
|
selectionlist = 'selectionlist',
|
||||||
error = 'error'
|
error = 'error'
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,10 @@ import { t } from '@lingui/macro';
|
|||||||
import { IconPackages } from '@tabler/icons-react';
|
import { IconPackages } from '@tabler/icons-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../App';
|
||||||
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||||
|
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||||
|
import { apiUrl } from '../states/ApiState';
|
||||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -204,7 +207,7 @@ export function usePartParameterFields({
|
|||||||
setChoices(
|
setChoices(
|
||||||
_choices.map((choice) => {
|
_choices.map((choice) => {
|
||||||
return {
|
return {
|
||||||
label: choice.trim(),
|
display_name: choice.trim(),
|
||||||
value: choice.trim()
|
value: choice.trim()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -214,6 +217,22 @@ export function usePartParameterFields({
|
|||||||
setChoices([]);
|
setChoices([]);
|
||||||
setFieldType('string');
|
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 {
|
} else {
|
||||||
setChoices([]);
|
setChoices([]);
|
||||||
setFieldType('string');
|
setFieldType('string');
|
||||||
|
117
src/frontend/src/forms/selectionListFields.tsx
Normal file
117
src/frontend/src/forms/selectionListFields.tsx
Normal file
@ -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 type {
|
||||||
|
ApiFormFieldSet,
|
||||||
|
ApiFormFieldType
|
||||||
|
} from '../components/forms/fields/ApiFormField';
|
||||||
|
import type { 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 (
|
||||||
|
<Table.Tr key={`table-row-${props.item.pk}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<StandaloneField fieldName='value' fieldDefinition={valueField} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<StandaloneField fieldName='label' fieldDefinition={labelField} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<StandaloneField
|
||||||
|
fieldName='description'
|
||||||
|
fieldDefinition={descriptionField}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<StandaloneField fieldName='active' fieldDefinition={activeField} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<BuildAllocateLineRow props={row} />
|
||||||
|
),
|
||||||
|
addRow: () => {
|
||||||
|
return {
|
||||||
|
value: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -66,6 +66,8 @@ const MachineManagementPanel = Loadable(
|
|||||||
lazy(() => import('./MachineManagementPanel'))
|
lazy(() => import('./MachineManagementPanel'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel')));
|
||||||
|
|
||||||
const ErrorReportTable = Loadable(
|
const ErrorReportTable = Loadable(
|
||||||
lazy(() => import('../../../../tables/settings/ErrorTable'))
|
lazy(() => import('../../../../tables/settings/ErrorTable'))
|
||||||
);
|
);
|
||||||
@ -86,6 +88,10 @@ const CustomStateTable = Loadable(
|
|||||||
lazy(() => import('../../../../tables/settings/CustomStateTable'))
|
lazy(() => import('../../../../tables/settings/CustomStateTable'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const CustomUnitsTable = Loadable(
|
||||||
|
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
|
||||||
|
);
|
||||||
|
|
||||||
const PartParameterTemplateTable = Loadable(
|
const PartParameterTemplateTable = Loadable(
|
||||||
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
|
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
|
||||||
);
|
);
|
||||||
@ -169,7 +175,7 @@ export default function AdminCenter() {
|
|||||||
name: 'part-parameters',
|
name: 'part-parameters',
|
||||||
label: t`Part Parameters`,
|
label: t`Part Parameters`,
|
||||||
icon: <IconList />,
|
icon: <IconList />,
|
||||||
content: <PartParameterTemplateTable />
|
content: <PartParameterPanel />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'category-parameters',
|
name: 'category-parameters',
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
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 (
|
||||||
|
<Accordion defaultValue='parametertemplate'>
|
||||||
|
<Accordion.Item value='parametertemplate' key='parametertemplate'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>{t`Part Parameter Template`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<PartParameterTemplateTable />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item value='selectionlist' key='selectionlist'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>{t`Selection Lists`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<SelectionListTable />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() {
|
|||||||
description: {},
|
description: {},
|
||||||
units: {},
|
units: {},
|
||||||
choices: {},
|
choices: {},
|
||||||
checkbox: {}
|
checkbox: {},
|
||||||
|
selectionlist: {}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
134
src/frontend/src/tables/part/SelectionListTable.tsx
Normal file
134
src/frontend/src/tables/part/SelectionListTable.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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 type { TableColumn } from '../Column';
|
||||||
|
import { BooleanColumn } from '../ColumnRenderers';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { type 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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'entry_count'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 [
|
||||||
|
<AddItemButton
|
||||||
|
key='add-selection-list'
|
||||||
|
onClick={() => newSelectionList.open()}
|
||||||
|
tooltip={t`Add Selection List`}
|
||||||
|
/>
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{newSelectionList.modal}
|
||||||
|
{editSelectionList.modal}
|
||||||
|
{deleteSelectionList.modal}
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(ApiEndpoints.selectionlist_list)}
|
||||||
|
tableState={table}
|
||||||
|
columns={columns}
|
||||||
|
props={{
|
||||||
|
rowActions: rowActions,
|
||||||
|
tableActions: tableActions,
|
||||||
|
enableDownload: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
100
src/frontend/tests/settings/selectionList.spec.ts
Normal file
100
src/frontend/tests/settings/selectionList.spec.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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('button', { name: 'Part Parameter Template' }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
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.getByRole('button', { name: 'Selection Lists' }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
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.getByRole('button', { name: 'Part Parameter Template' }).click();
|
||||||
|
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();
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user