mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-25 16:17:58 +00:00
[API] Enhanced filtering for generic parameters (#11383)
* Enhanced filtering for parameter templates - Allow filtering by model ID as well as model type * Enhanced filtering for ParameterTemplate - Required for the parameteric data tables - Enable filtering by base model ID - Enable filtering by related model ID * Bump API version * Remove outdated comments * Fix typo * Remove debug statement * Added unit tests * Playwright tests * Fix unit test * Bump requirements * Revert
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 454
|
||||
INVENTREE_API_VERSION = 455
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v455 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11383
|
||||
- Adds "exists_for_model_id" filter to ParameterTemplate API endpoint
|
||||
- Adds "exists_for_related_model" filter to ParameterTemplate API endpoint
|
||||
- Adds "exists_for_related_model_id" filter to ParameterTemplate API endpoint
|
||||
|
||||
v454 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11379
|
||||
- Adds "purchase_price" ordering option to StockItem API endpoint
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import json.decoder
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from django.http.response import HttpResponse
|
||||
@@ -35,6 +35,7 @@ import common.filters
|
||||
import common.models
|
||||
import common.serializers
|
||||
import InvenTree.conversion
|
||||
import InvenTree.models
|
||||
import InvenTree.ready
|
||||
from common.icons import get_icon_packs
|
||||
from common.settings import get_global_setting
|
||||
@@ -839,7 +840,13 @@ class ParameterTemplateFilter(FilterSet):
|
||||
content_type = common.filters.determine_content_type(value)
|
||||
|
||||
if not content_type:
|
||||
return queryset.none()
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
# If the 'filter_exists_for_model_id' filter is applied, defer to that
|
||||
if self.request.query_params.get('exists_for_model_id', None):
|
||||
return queryset
|
||||
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
|
||||
@@ -853,6 +860,160 @@ class ParameterTemplateFilter(FilterSet):
|
||||
# Return only those ParameterTemplates which have at least one Parameter for the given model type
|
||||
return queryset.filter(parameter_count__gt=0)
|
||||
|
||||
exists_for_model_id = rest_filters.NumberFilter(
|
||||
method='filter_exists_for_model_id', label='Exists For Model ID'
|
||||
)
|
||||
|
||||
def filter_exists_for_model_id(self, queryset, name, value):
|
||||
"""Filter queryset to include only ParameterTemplates which have at least one Parameter for the given model type and model id.
|
||||
|
||||
Notes:
|
||||
- This filter can only be applied if the 'exists_for_model' filter is also applied, as the model_id is only meaningful in the context of a particular model type.
|
||||
|
||||
Reference: https://github.com/inventree/InvenTree/issues/11381
|
||||
"""
|
||||
exists_for_model = self.request.query_params.get('exists_for_model', None)
|
||||
|
||||
if not exists_for_model:
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
content_type = common.filters.determine_content_type(exists_for_model)
|
||||
|
||||
if not content_type:
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
model_class = content_type.model_class()
|
||||
|
||||
# Try to find the model instance
|
||||
try:
|
||||
instance = model_class.objects.get(pk=value)
|
||||
except (model_class.DoesNotExist, ValueError):
|
||||
# If the model instance does not exist, then we can return an empty queryset
|
||||
raise ValidationError({
|
||||
'exists_for_model_id': 'Invalid model id provided - no such instance for the given model type'
|
||||
})
|
||||
|
||||
# If the provided model is a "tree" structure, then we should also include any child objects in the filter
|
||||
if isinstance(instance, InvenTree.models.InvenTreeTree):
|
||||
id_values = list(
|
||||
instance.get_descendants(include_self=True).values_list('pk', flat=True)
|
||||
)
|
||||
else:
|
||||
id_values = [instance.pk]
|
||||
|
||||
# Now, filter against model type and model id
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
|
||||
filters = {'model_type': content_type, 'model_id__in': id_values}
|
||||
|
||||
# Annotate the queryset to determine which ParameterTemplates have at least one Parameter defined
|
||||
queryset = queryset.annotate(
|
||||
parameter_count=SubqueryCount('parameters', filter=Q(**filters))
|
||||
)
|
||||
|
||||
return queryset.filter(parameter_count__gt=0)
|
||||
|
||||
exists_for_related_model = rest_filters.CharFilter(
|
||||
method='filter_exists_for_related_model', label='Exists For Related Model'
|
||||
)
|
||||
|
||||
def filter_exists_for_related_model(self, queryset, name, value):
|
||||
"""Filter applied to map parameter templates to a particular model relation against the target model.
|
||||
|
||||
For instance, specify 'category' to filter part parameters which exist for any part in that category.
|
||||
|
||||
Note:
|
||||
- This filter has no effect on its own
|
||||
- It requires the 'exists_for_model' filter to be applied (to specify the base model)
|
||||
- It requires the 'exists_for_related_model_id' filter to be applied also (to specify the related model id)
|
||||
"""
|
||||
return queryset
|
||||
|
||||
exists_for_related_model_id = rest_filters.NumberFilter(
|
||||
method='filter_exists_for_related_model_id', label='Exists For Model ID'
|
||||
)
|
||||
|
||||
def filter_exists_for_related_model_id(self, queryset, name, value):
|
||||
"""Filter queryset to include only ParameterTemplates which have at least one Parameter for the given related model type and model id.
|
||||
|
||||
Notes:
|
||||
- This filter can only be applied if the 'exists_for_model' filter is also applied, as the model_id is only meaningful in the context of a base model
|
||||
- This filter can only be applied if the 'exists_for_related_model' filter is also applied, as the related model id is only meaningful in the context of a particular model relation
|
||||
|
||||
Example: To filter part parameters which have at least one parameter defined for any part in category 5, you could apply the following filters:
|
||||
- exists_for_model=part
|
||||
- exists_for_related_model=category
|
||||
- exists_for_related_model_id=5
|
||||
"""
|
||||
model = self.request.query_params.get('exists_for_model', None)
|
||||
related_model = self.request.query_params.get('exists_for_related_model', None)
|
||||
|
||||
if not model or not related_model:
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
# Determine content type for the base model, to ensure they are valid
|
||||
model_type = common.filters.determine_content_type(model)
|
||||
|
||||
if not model_type:
|
||||
return queryset.none()
|
||||
|
||||
# Determine the model class for the 'related' model
|
||||
try:
|
||||
related_model_field = model_type.model_class()._meta.get_field(
|
||||
related_model
|
||||
)
|
||||
except FieldDoesNotExist:
|
||||
raise ValidationError({
|
||||
'exists_for_related_model': 'Invalid related model - no such field on the base model'
|
||||
})
|
||||
if related_model_field := model_type.model_class()._meta.get_field(
|
||||
related_model
|
||||
):
|
||||
related_model_class = related_model_field.related_model
|
||||
else:
|
||||
# Return an empty queryset if the provided related model is invalid
|
||||
return queryset.none()
|
||||
|
||||
# Find all instances of the related model which match the provided related model id
|
||||
try:
|
||||
related_instance = related_model_class.objects.get(pk=value)
|
||||
except (related_model_class.DoesNotExist, ValueError):
|
||||
return queryset.none()
|
||||
|
||||
# Account for potential tree structure in the related model
|
||||
if isinstance(related_instance, InvenTree.models.InvenTreeTree):
|
||||
related_instances = list(
|
||||
related_instance.get_descendants(include_self=True).values_list(
|
||||
'pk', flat=True
|
||||
)
|
||||
)
|
||||
else:
|
||||
related_instances = [related_instance.pk]
|
||||
|
||||
# Next, find all instances of the base model which are related to the related model instances
|
||||
model_instances = model_type.model_class().objects.filter(**{
|
||||
f'{related_model}__in': related_instances
|
||||
})
|
||||
model_instance_ids = list(model_instances.values_list('pk', flat=True))
|
||||
|
||||
# Now, filter against model type and model id
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
|
||||
filters = {'model_type': model_type, 'model_id__in': model_instance_ids}
|
||||
|
||||
# Annotate the queryset to determine which ParameterTemplates have at least one Parameter defined
|
||||
queryset = queryset.annotate(
|
||||
parameter_count=SubqueryCount('parameters', filter=Q(**filters))
|
||||
)
|
||||
|
||||
return queryset.filter(parameter_count__gt=0)
|
||||
|
||||
|
||||
class ParameterTemplateMixin:
|
||||
"""Mixin class for ParameterTemplate views."""
|
||||
|
||||
@@ -237,6 +237,217 @@ class ParameterAPITests(InvenTreeAPITestCase):
|
||||
f'Incorrect number of templates for model "{model_name}"',
|
||||
)
|
||||
|
||||
def test_template_extended_filters(self):
|
||||
"""Unit testing for more complex filters on the ParameterTemplate endpoint.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/11383
|
||||
|
||||
In these tests we will filter by complex model relations.
|
||||
"""
|
||||
from part.models import Part, PartCategory
|
||||
|
||||
# Create some part categories
|
||||
cat_mech = PartCategory.objects.create(
|
||||
name='Mechanical', description='Mechanical components'
|
||||
)
|
||||
cat_elec = PartCategory.objects.create(
|
||||
name='Electronics', description='Electronic components'
|
||||
)
|
||||
cat_pass = PartCategory.objects.create(
|
||||
name='Passive', description='Passive electronic components', parent=cat_elec
|
||||
)
|
||||
cat_res = PartCategory.objects.create(
|
||||
name='Resistors', description='Resistor components', parent=cat_pass
|
||||
)
|
||||
cat_cap = PartCategory.objects.create(
|
||||
name='Capacitors', description='Capacitor components', parent=cat_pass
|
||||
)
|
||||
|
||||
# Create some parts
|
||||
capacitors = [
|
||||
Part.objects.create(
|
||||
name=f'Capacitor {ii}', description='A capacitor', category=cat_cap
|
||||
)
|
||||
for ii in range(5)
|
||||
]
|
||||
|
||||
resistors = [
|
||||
Part.objects.create(
|
||||
name=f'Resistor {ii}', description='A resistor', category=cat_res
|
||||
)
|
||||
for ii in range(5)
|
||||
]
|
||||
|
||||
# Create some ParameterTemplates which relate to the category of the part
|
||||
resistance = common.models.ParameterTemplate.objects.create(
|
||||
name='Resistance', description='The resistance of a part', units='Ohms'
|
||||
)
|
||||
|
||||
capacitance = common.models.ParameterTemplate.objects.create(
|
||||
name='Capacitance', description='The capacitance of a part', units='Farads'
|
||||
)
|
||||
|
||||
tolerance = common.models.ParameterTemplate.objects.create(
|
||||
name='Tolerance', description='The tolerance of a part', units='%'
|
||||
)
|
||||
|
||||
for idx, resistor in enumerate(resistors):
|
||||
common.models.Parameter.objects.create(
|
||||
template=resistance,
|
||||
model_type=resistor.get_content_type(),
|
||||
model_id=resistor.pk,
|
||||
data=f'{10 * (idx + 1)}k',
|
||||
)
|
||||
|
||||
common.models.Parameter.objects.create(
|
||||
template=tolerance,
|
||||
model_type=resistor.get_content_type(),
|
||||
model_id=resistor.pk,
|
||||
data=f'{idx + 1}%',
|
||||
)
|
||||
|
||||
for idx, capacitor in enumerate(capacitors):
|
||||
common.models.Parameter.objects.create(
|
||||
template=capacitance,
|
||||
model_type=capacitor.get_content_type(),
|
||||
model_id=capacitor.pk,
|
||||
data=f'{10 * (idx + 1)}uF',
|
||||
)
|
||||
|
||||
common.models.Parameter.objects.create(
|
||||
template=tolerance,
|
||||
model_type=capacitor.get_content_type(),
|
||||
model_id=capacitor.pk,
|
||||
data=f'{5 * (idx + 1)}%',
|
||||
)
|
||||
|
||||
# Ensure that we have the expected number of templates and parameters created for testing
|
||||
self.assertEqual(common.models.ParameterTemplate.objects.count(), 3)
|
||||
self.assertEqual(common.models.Parameter.objects.count(), 20)
|
||||
|
||||
# Now, we have some data - let's apply some filtering
|
||||
url = reverse('api-parameter-template-list')
|
||||
|
||||
# Return *all* results, without filters
|
||||
response = self.get(url)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by 'exists_for_model'
|
||||
for model_name, count in {
|
||||
'part.part': 3,
|
||||
'part': 3,
|
||||
'company': 0,
|
||||
'build': 0,
|
||||
}.items():
|
||||
response = self.get(url, data={'exists_for_model': model_name})
|
||||
n = len(response.data)
|
||||
self.assertEqual(
|
||||
n,
|
||||
count,
|
||||
f'Incorrect number of templates ({n}) for model "{model_name}"',
|
||||
)
|
||||
|
||||
# Filter by 'exists_for_model' and 'exists_for_model_id'
|
||||
res = resistors[0]
|
||||
response = self.get(
|
||||
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': res.pk}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(resistance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
cap = capacitors[0]
|
||||
response = self.get(
|
||||
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': cap.pk}
|
||||
)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(capacitance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
# Filter by 'exists_for_related_model' (test the "capacitor" relationship)
|
||||
|
||||
# Check the 'capacitor' category
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'exists_for_model': 'part.part',
|
||||
'exists_for_related_model': 'category',
|
||||
'exists_for_related_model_id': cat_cap.pk,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(capacitance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
# Check the 'electronics' category - this should return all parameters
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'exists_for_model': 'part.part',
|
||||
'exists_for_related_model': 'category',
|
||||
'exists_for_related_model_id': cat_elec.pk,
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(resistance.pk, pk_list)
|
||||
self.assertIn(capacitance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
# Check the 'mechanical' category - this should return no parameters
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'exists_for_model': 'part.part',
|
||||
'exists_for_related_model': 'category',
|
||||
'exists_for_related_model_id': cat_mech.pk,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_invalid_filters(self):
|
||||
"""Test error messages for invalid filter combinations."""
|
||||
url = reverse('api-parameter-template-list')
|
||||
|
||||
# Invalid 'exists_for_model' value
|
||||
response = self.get(
|
||||
url, {'exists_for_model': 'asdf---invalid---model'}, expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Invalid model type provided', str(response.data['exists_for_model'])
|
||||
)
|
||||
|
||||
# Invalid 'exists_for_model_id' value
|
||||
for model_id in ['not_an_integer', -1, 9999]:
|
||||
response = self.get(
|
||||
url,
|
||||
{'exists_for_model': 'part.part', 'exists_for_model_id': model_id},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
# Invalid 'exists_for_related_model' value
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'exists_for_model': 'part',
|
||||
'exists_for_related_model': 'invalid_field',
|
||||
'exists_for_related_model_id': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'no such field on the base model',
|
||||
str(response.data['exists_for_related_model']),
|
||||
)
|
||||
|
||||
def test_parameter_api(self):
|
||||
"""Test Parameter API functionality."""
|
||||
# Create a simple part to test with
|
||||
|
||||
@@ -1659,9 +1659,9 @@ pynacl==1.6.2 \
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# paramiko
|
||||
pypdf==6.7.1 \
|
||||
--hash=sha256:6b7a63be5563a0a35d54c6d6b550d75c00b8ccf36384be96365355e296e6b3b0 \
|
||||
--hash=sha256:a02ccbb06463f7c334ce1612e91b3e68a8e827f3cee100b9941771e6066b094e
|
||||
pypdf==6.6.2 \
|
||||
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \
|
||||
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
||||
@@ -101,12 +101,18 @@ function ParameterCell({
|
||||
*/
|
||||
export default function ParametricDataTable({
|
||||
modelType,
|
||||
modelId,
|
||||
relatedModel,
|
||||
relatedModelId,
|
||||
endpoint,
|
||||
queryParams,
|
||||
customFilters,
|
||||
customColumns
|
||||
}: {
|
||||
modelType: ModelType;
|
||||
modelId?: number;
|
||||
relatedModel?: string;
|
||||
relatedModelId?: number;
|
||||
endpoint: ApiEndpoints | string;
|
||||
queryParams?: Record<string, any>;
|
||||
customFilters?: TableFilter[];
|
||||
@@ -125,8 +131,12 @@ export default function ParametricDataTable({
|
||||
.get(apiUrl(ApiEndpoints.parameter_template_list), {
|
||||
params: {
|
||||
active: true,
|
||||
ordering: 'name',
|
||||
for_model: modelType,
|
||||
exists_for_model: modelType
|
||||
exists_for_model: modelType,
|
||||
exists_for_model_id: modelId ?? undefined,
|
||||
exists_for_related_model: relatedModel ?? undefined,
|
||||
exists_for_related_model_id: relatedModelId ?? undefined
|
||||
}
|
||||
})
|
||||
.then((response) => response.data);
|
||||
|
||||
@@ -56,6 +56,8 @@ export default function ParametricPartTable({
|
||||
return (
|
||||
<ParametricDataTable
|
||||
modelType={ModelType.part}
|
||||
relatedModel={'category'}
|
||||
relatedModelId={categoryId}
|
||||
endpoint={ApiEndpoints.part_list}
|
||||
customColumns={customColumns}
|
||||
customFilters={customFilters}
|
||||
|
||||
@@ -180,3 +180,33 @@ export const toggleColumnSorting = async (page: Page, columnName: string) => {
|
||||
await page.waitForTimeout(50);
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
// Display the 'table' view
|
||||
export const showTableView = async (page: Page) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
// Display the 'parameteric' view
|
||||
export const showParametricView = async (page: Page) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
// Display the 'calendar' view
|
||||
export const showCalendarView = async (page: Page) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
||||
.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
};
|
||||
|
||||
// Check for an expected number of columns in the visible table
|
||||
export const expectTableColumnCount = async (page: Page, count: number) => {
|
||||
const columns = page.locator('table thead tr th');
|
||||
await expect(columns).toHaveCount(count);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
getRowFromCell,
|
||||
loadTab,
|
||||
navigate,
|
||||
setTableChoiceFilter
|
||||
setTableChoiceFilter,
|
||||
showCalendarView,
|
||||
showParametricView,
|
||||
showTableView
|
||||
} from '../helpers.ts';
|
||||
import { doCachedLogin } from '../login.ts';
|
||||
|
||||
@@ -17,18 +20,10 @@ test('Build - Index', async ({ browser }) => {
|
||||
await loadTab(page, 'Build Orders');
|
||||
|
||||
// Ensure all data views are available
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showCalendarView(page);
|
||||
await page.getByRole('button', { name: 'action-button-next-month' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showTableView(page);
|
||||
});
|
||||
|
||||
test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { clickOnParamFilter, loadTab, navigate } from '../helpers.js';
|
||||
import {
|
||||
clickOnParamFilter,
|
||||
loadTab,
|
||||
navigate,
|
||||
showParametricView
|
||||
} from '../helpers.js';
|
||||
import { doCachedLogin } from '../login.js';
|
||||
|
||||
test('Company', async ({ browser }) => {
|
||||
@@ -49,9 +54,7 @@ test('Company - Parameters', async ({ browser }) => {
|
||||
});
|
||||
|
||||
// Show parametric view
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
|
||||
// Filter by "payment terms" parameter value
|
||||
await clickOnParamFilter(page, 'Payment Terms');
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
clickOnParamFilter,
|
||||
clickOnRowMenu,
|
||||
deletePart,
|
||||
expectTableColumnCount,
|
||||
getRowFromCell,
|
||||
loadTab,
|
||||
navigate,
|
||||
setTableChoiceFilter
|
||||
setTableChoiceFilter,
|
||||
showParametricView,
|
||||
showTableView
|
||||
} from '../helpers';
|
||||
import { doCachedLogin } from '../login';
|
||||
import { setPluginState, setSettingState } from '../settings';
|
||||
@@ -499,6 +502,58 @@ test('Parts - Attachments', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Parts - Parameters by Category', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/category/4/parts' });
|
||||
|
||||
await showParametricView(page);
|
||||
|
||||
// Check for expected parameter columns
|
||||
for (const col of [
|
||||
'Total Stock',
|
||||
'Capacitance [F]',
|
||||
'Power [W]',
|
||||
'Resistance [ohms]'
|
||||
]) {
|
||||
await page.getByRole('button', { name: col }).waitFor();
|
||||
}
|
||||
|
||||
await expectTableColumnCount(page, 9);
|
||||
|
||||
// Now let's go to the "resistors" category
|
||||
await navigate(page, 'part/category/5/parts');
|
||||
await showParametricView(page);
|
||||
|
||||
// Fewer parameter templates displayed here
|
||||
await expectTableColumnCount(page, 7);
|
||||
|
||||
// Check for expected parameter columns
|
||||
for (const col of [
|
||||
'Total Stock',
|
||||
'Tolerance [percent]',
|
||||
'Power [W]',
|
||||
'Resistance [ohms]'
|
||||
]) {
|
||||
await page.getByRole('button', { name: col }).waitFor();
|
||||
}
|
||||
|
||||
// Finally, let's go to the "capacitors" category, which has a different set of parameter templates
|
||||
await navigate(page, 'part/category/6/parts');
|
||||
await showParametricView(page);
|
||||
await expectTableColumnCount(page, 7);
|
||||
|
||||
for (const col of [
|
||||
'Total Stock',
|
||||
'Tolerance [percent]',
|
||||
'Polarized',
|
||||
'Capacitance [F]'
|
||||
]) {
|
||||
await page.getByRole('button', { name: col }).waitFor();
|
||||
}
|
||||
|
||||
// Reset to the table view
|
||||
await showTableView(page);
|
||||
});
|
||||
|
||||
test('Parts - Parameters', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/69/parameters' });
|
||||
|
||||
@@ -562,10 +617,8 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/' });
|
||||
|
||||
await loadTab(page, 'Parts', true);
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
|
||||
await showParametricView(page);
|
||||
await clearTableFilters(page);
|
||||
|
||||
// All parts should be available (no filters applied)
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
loadTab,
|
||||
navigate,
|
||||
openFilterDrawer,
|
||||
setTableChoiceFilter
|
||||
setTableChoiceFilter,
|
||||
showCalendarView,
|
||||
showParametricView,
|
||||
showTableView
|
||||
} from '../helpers.ts';
|
||||
import { doCachedLogin } from '../login.ts';
|
||||
|
||||
@@ -18,26 +21,14 @@ test('Purchasing - Index', async ({ browser }) => {
|
||||
|
||||
// Purchase Orders tab
|
||||
await loadTab(page, 'Purchase Orders');
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'calendar-select-month' }).waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showCalendarView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Suppliers tab
|
||||
await loadTab(page, 'Suppliers');
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Check for expected values
|
||||
await clearTableFilters(page);
|
||||
@@ -45,12 +36,8 @@ test('Purchasing - Index', async ({ browser }) => {
|
||||
|
||||
// Supplier parts tab
|
||||
await loadTab(page, 'Supplier Parts');
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Check for expected values
|
||||
await clearTableFilters(page);
|
||||
@@ -60,12 +47,8 @@ test('Purchasing - Index', async ({ browser }) => {
|
||||
|
||||
// Manufacturers tab
|
||||
await loadTab(page, 'Manufacturers');
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Check for expected values
|
||||
await clearTableFilters(page);
|
||||
@@ -76,12 +59,8 @@ test('Purchasing - Index', async ({ browser }) => {
|
||||
|
||||
// Manufacturer parts tab
|
||||
await loadTab(page, 'Manufacturer Parts');
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Check for expected values
|
||||
await clearTableFilters(page);
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
clickOnRowMenu,
|
||||
globalSearch,
|
||||
loadTab,
|
||||
setTableChoiceFilter
|
||||
setTableChoiceFilter,
|
||||
showCalendarView,
|
||||
showParametricView,
|
||||
showTableView
|
||||
} from '../helpers.ts';
|
||||
import { doCachedLogin } from '../login.ts';
|
||||
|
||||
@@ -18,15 +21,9 @@ test('Sales Orders - Tabs', async ({ browser }) => {
|
||||
await loadTab(page, 'Sales Orders');
|
||||
await page.waitForURL('**/web/sales/index/salesorders');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showCalendarView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Pending Shipments panel
|
||||
await loadTab(page, 'Pending Shipments');
|
||||
@@ -37,25 +34,15 @@ test('Sales Orders - Tabs', async ({ browser }) => {
|
||||
await loadTab(page, 'Return Orders');
|
||||
await page.getByRole('cell', { name: 'NOISE-COMPLAINT' }).waitFor();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showCalendarView(page);
|
||||
await showTableView(page);
|
||||
|
||||
// Customers
|
||||
await loadTab(page, 'Customers');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||
.click();
|
||||
await showParametricView(page);
|
||||
await showTableView(page);
|
||||
|
||||
await page.getByText('Customer A').click();
|
||||
await loadTab(page, 'Notes');
|
||||
|
||||
Reference in New Issue
Block a user