mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-13 18:45:40 +00:00
[Feature] Filter by parameter (#9739)
* Add shell task * Filter parts by parameter value * Allow more operation types * Working on table filtering * Filter improvements * Update on enter key * Improved query logic * Enable filter for "generic" parameter types * Placeholder text * Documentation updates * Fix typo * Fix for boolean part parameter field * Add API unit testings * Cleanup * add playwright tests
This commit is contained in:
BIN
docs/docs/assets/images/part/filter_by_param.png
Normal file
BIN
docs/docs/assets/images/part/filter_by_param.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
BIN
docs/docs/assets/images/part/filter_with_unit.png
Normal file
BIN
docs/docs/assets/images/part/filter_with_unit.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
docs/docs/assets/images/part/multiple_param_filters.png
Normal file
BIN
docs/docs/assets/images/part/multiple_param_filters.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 50 KiB |
BIN
docs/docs/assets/images/part/remove_param_filter.png
Normal file
BIN
docs/docs/assets/images/part/remove_param_filter.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@ -7,7 +7,7 @@ title: Part Parameters
|
|||||||
A part *parameter* describes a particular "attribute" or "property" of a specific part.
|
A part *parameter* describes a particular "attribute" or "property" of a specific part.
|
||||||
|
|
||||||
Part parameters are located in the "Parameters" tab, on each part detail page.
|
Part parameters are located in the "Parameters" tab, on each part detail page.
|
||||||
There is no limit for the number of part parameters and they are fully customizable through the use of [parameters templates](#parameter-templates).
|
There is no limit for the number of part parameters and they are fully customizable through the use of [parameter templates](#parameter-templates).
|
||||||
|
|
||||||
Here is an example of parameters for a capacitor:
|
Here is an example of parameters for a capacitor:
|
||||||
|
|
||||||
@ -66,6 +66,37 @@ The parametric parts table allows the returned parts to be sorted by particular
|
|||||||
|
|
||||||
{{ image("part/part_sort_by_param.png", "Sort by Parameter") }}
|
{{ image("part/part_sort_by_param.png", "Sort by Parameter") }}
|
||||||
|
|
||||||
|
### Filtering by Parameter Value
|
||||||
|
|
||||||
|
The parametric parts table allows the returned parts to be filtered by particular parameter values. Click on the {{ icon("filter") }} button associated with the particular parameter, and enter the value you wish to filter against:
|
||||||
|
|
||||||
|
{{ image("part/filter_by_param.png", "Filter by Parameter") }}
|
||||||
|
|
||||||
|
The available filter options depend on the type of parameter being filtered. For example, a parameter with a limited set of choices will allow you to filter by those choices, while a numeric parameter will allow you to filter against a specific value and operator (e.g. greater than, less than, etc.).
|
||||||
|
|
||||||
|
#### Filtering by Multiple Parameters
|
||||||
|
|
||||||
|
Multiple parameters can be used to filter the parametric table. Simply add a new filter for each parameter you wish to filter against. The results will be filtered to include only parts which match *all* of the specified filters.
|
||||||
|
|
||||||
|
Each parameter column indicates whether a filter is currently applied:
|
||||||
|
|
||||||
|
{{ image("part/multiple_param_filters.png", "Multiple Parameter Filters") }}
|
||||||
|
|
||||||
|
#### Unit-Aware Filtering
|
||||||
|
|
||||||
|
When filtering against a parameter which has a unit defined, you can specify the value in any compatible unit. The system will automatically convert the value to the base unit defined for that parameter template.
|
||||||
|
|
||||||
|
For example, to show all parts with a *Resistance* parameter of greater than 10kΩ, you can enter `10k` or `10000` in the filter field, and the system will correctly interpret this as 10,000 ohms.
|
||||||
|
|
||||||
|
{{ image("part/filter_with_unit.png", "Unit Aware Filters") }}
|
||||||
|
|
||||||
|
#### Remove Filter
|
||||||
|
|
||||||
|
To remove a filter against a given parameter, click on the {{ icon("circle-x", color='red') }} button associated with that filter:
|
||||||
|
|
||||||
|
{{ image("part/remove_param_filter.png", "Remove Parameter Filter") }}
|
||||||
|
|
||||||
|
|
||||||
## Parameter Units
|
## Parameter Units
|
||||||
|
|
||||||
The *units* field (which is defined against a [parameter template](#parameter-templates)) defines the base unit of that template. Any parameters which are created against that unit *must* be specified in compatible units.
|
The *units* field (which is defined against a [parameter template](#parameter-templates)) defines the base unit of that template. Any parameters which are created against that unit *must* be specified in compatible units.
|
||||||
@ -83,7 +114,7 @@ If a part parameter is created with a value which is incompatible with the units
|
|||||||
|
|
||||||
This behaviour can be disabled if required, so that any parameter value is accepted.
|
This behaviour can be disabled if required, so that any parameter value is accepted.
|
||||||
|
|
||||||
### Parameter Sorting
|
### Parameter Unit Sorting
|
||||||
|
|
||||||
Parameter sorting takes unit conversion into account, meaning that values provided in different (but compatible) units are sorted correctly:
|
Parameter sorting takes unit conversion into account, meaning that values provided in different (but compatible) units are sorted correctly:
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@ from drf_spectacular.utils import extend_schema_field
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
import build.admin
|
|
||||||
import build.serializers
|
import build.serializers
|
||||||
import common.models
|
import common.models
|
||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
|
@ -1339,15 +1339,51 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
queryset = self.filter_parametric_data(queryset)
|
queryset = self.filter_parametric_data(queryset)
|
||||||
|
queryset = self.order_by_parameter(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def filter_parametric_data(self, queryset):
|
def filter_parametric_data(self, queryset):
|
||||||
"""Filter queryset against part parameters.
|
"""Filter queryset against part parameters.
|
||||||
|
|
||||||
Here we can perform a number of different functions:
|
Used to filter returned parts based on their parameter values.
|
||||||
|
|
||||||
|
To filter based on parameter value, supply query parameters like:
|
||||||
|
- parameter_<x>=<value>
|
||||||
|
- parameter_<x>_gt=<value>
|
||||||
|
- parameter_<x>_lte=<value>
|
||||||
|
|
||||||
|
where:
|
||||||
|
- <x> is the ID of the PartParameterTemplate.
|
||||||
|
- <value> is the value to filter against.
|
||||||
|
"""
|
||||||
|
# Allowed lookup operations for parameter values
|
||||||
|
operations = '|'.join(['gt', 'lt', 'gte', 'lte'])
|
||||||
|
regex_pattern = rf'^parameter_(\d+)(_({operations}))?$'
|
||||||
|
|
||||||
|
for param in self.request.query_params:
|
||||||
|
result = re.match(regex_pattern, param)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
continue
|
||||||
|
|
||||||
|
template_id = result.group(1)
|
||||||
|
operator = result.group(3) or ''
|
||||||
|
|
||||||
|
if operator:
|
||||||
|
operator = '__' + operator
|
||||||
|
|
||||||
|
value = self.request.query_params.get(param, None)
|
||||||
|
|
||||||
|
queryset = part.filters.filter_by_parameter(
|
||||||
|
queryset, template_id, value, func=operator
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def order_by_parameter(self, queryset):
|
||||||
|
"""Perform queryset ordering based on parameter value.
|
||||||
|
|
||||||
Ordering Based on Parameter Value:
|
|
||||||
- Used if the 'ordering' query param points to a parameter
|
- Used if the 'ordering' query param points to a parameter
|
||||||
- e.g. '&ordering=param_<id>' where <id> specifies the PartParameterTemplate
|
- e.g. '&ordering=param_<id>' where <id> specifies the PartParameterTemplate
|
||||||
- Only parts which have a matching parameter are returned
|
- Only parts which have a matching parameter are returned
|
||||||
|
@ -9,11 +9,6 @@ Useful References:
|
|||||||
- https://docs.djangoproject.com/en/4.0/ref/models/expressions/
|
- https://docs.djangoproject.com/en/4.0/ref/models/expressions/
|
||||||
- https://stackoverflow.com/questions/42543978/django-1-11-annotating-a-subquery-aggregate
|
- https://stackoverflow.com/questions/42543978/django-1-11-annotating-a-subquery-aggregate
|
||||||
|
|
||||||
Relevant PRs:
|
|
||||||
|
|
||||||
- https://github.com/inventree/InvenTree/pull/2797/
|
|
||||||
- https://github.com/inventree/InvenTree/pull/2827
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -38,6 +33,8 @@ from django.db.models.functions import Coalesce
|
|||||||
|
|
||||||
from sql_util.utils import SubquerySum
|
from sql_util.utils import SubquerySum
|
||||||
|
|
||||||
|
import InvenTree.conversion
|
||||||
|
import InvenTree.helpers
|
||||||
import part.models
|
import part.models
|
||||||
import stock.models
|
import stock.models
|
||||||
from build.status_codes import BuildStatusGroups
|
from build.status_codes import BuildStatusGroups
|
||||||
@ -343,7 +340,58 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''):
|
|||||||
Returns:
|
Returns:
|
||||||
A queryset of Part objects filtered by the given parameter
|
A queryset of Part objects filtered by the given parameter
|
||||||
"""
|
"""
|
||||||
# TODO
|
try:
|
||||||
|
template = part.models.PartParameterTemplate.objects.get(pk=template_id)
|
||||||
|
except (ValueError, part.models.PartParameterTemplate.DoesNotExist):
|
||||||
|
# Return queryset unchanged if the template does not exist
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
# Construct a "numeric" value
|
||||||
|
try:
|
||||||
|
value_numeric = float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
value_numeric = None
|
||||||
|
|
||||||
|
if template.checkbox:
|
||||||
|
# Account for 'boolean' parameter values
|
||||||
|
# Convert to "True" or "False" string in this case
|
||||||
|
bool_value = InvenTree.helpers.str2bool(value)
|
||||||
|
value_numeric = 1 if bool_value else 0
|
||||||
|
value = str(bool_value)
|
||||||
|
|
||||||
|
# Boolean filtering is limited to exact matches
|
||||||
|
func = ''
|
||||||
|
|
||||||
|
elif value_numeric is None and template.units:
|
||||||
|
# Convert the raw value to the units of the template parameter
|
||||||
|
try:
|
||||||
|
value_numeric = InvenTree.conversion.convert_physical_value(
|
||||||
|
value, template.units
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# The value cannot be converted - return an empty queryset
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Query for 'numeric' value - this has priority over 'string' value
|
||||||
|
q1 = Q(**{
|
||||||
|
'parameters__template': template,
|
||||||
|
'parameters__data_numeric__isnull': False,
|
||||||
|
f'parameters__data_numeric{func}': value_numeric,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Query for 'string' value
|
||||||
|
q2 = Q(**{
|
||||||
|
'parameters__template': template,
|
||||||
|
'parameters__data_numeric__isnull': True,
|
||||||
|
f'parameters__data{func}': str(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
if value_numeric is not None:
|
||||||
|
queryset = queryset.filter(q1 | q2).distinct()
|
||||||
|
else:
|
||||||
|
# If the value is not numeric, we only filter by the string value
|
||||||
|
queryset = queryset.filter(q2).distinct()
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
@ -445,3 +445,177 @@ class PartParameterTest(InvenTreeAPITestCase):
|
|||||||
for idx, expected in expectation.items():
|
for idx, expected in expectation.items():
|
||||||
actual = get_param_value(response, template.pk, idx)
|
actual = get_param_value(response, template.pk, idx)
|
||||||
self.assertEqual(actual, expected)
|
self.assertEqual(actual, expected)
|
||||||
|
|
||||||
|
|
||||||
|
class PartParameterFilterTest(InvenTreeAPITestCase):
|
||||||
|
"""Unit tests for filtering parts by parameter values."""
|
||||||
|
|
||||||
|
superuser = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
"""Setup test data for the filtering tests."""
|
||||||
|
super().setUpTestData()
|
||||||
|
|
||||||
|
cls.url = reverse('api-part-list')
|
||||||
|
|
||||||
|
# Create a number of part parameter templates
|
||||||
|
cls.template_length = PartParameterTemplate.objects.create(
|
||||||
|
name='Length', description='Length of the part', units='mm'
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.template_width = PartParameterTemplate.objects.create(
|
||||||
|
name='Width', description='Width of the part', units='mm'
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.template_ionized = PartParameterTemplate.objects.create(
|
||||||
|
name='Ionized', description='Is the part ionized?', checkbox=True
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.template_color = PartParameterTemplate.objects.create(
|
||||||
|
name='Color', description='Color of the part', choices='red,green,blue'
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.category = PartCategory.objects.create(
|
||||||
|
name='Test Category', description='A category for testing part parameters'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a number of parts
|
||||||
|
parts = [
|
||||||
|
Part(
|
||||||
|
name=f'Part {i}',
|
||||||
|
description=f'Description for part {i}',
|
||||||
|
category=cls.category,
|
||||||
|
IPN=f'PART-{i:03d}',
|
||||||
|
level=0,
|
||||||
|
tree_id=0,
|
||||||
|
lft=0,
|
||||||
|
rght=0,
|
||||||
|
)
|
||||||
|
for i in range(1, 51)
|
||||||
|
]
|
||||||
|
|
||||||
|
Part.objects.bulk_create(parts)
|
||||||
|
|
||||||
|
# Create parameters for each part
|
||||||
|
parameters = []
|
||||||
|
|
||||||
|
for ii, part in enumerate(Part.objects.all()):
|
||||||
|
parameters.append(
|
||||||
|
PartParameter(
|
||||||
|
part=part,
|
||||||
|
template=cls.template_length,
|
||||||
|
data=(ii * 10) + 5, # Length in mm
|
||||||
|
data_numeric=(ii * 10) + 5, # Numeric value for length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
parameters.append(
|
||||||
|
PartParameter(
|
||||||
|
part=part,
|
||||||
|
template=cls.template_width,
|
||||||
|
data=(50 - ii) * 5 + 2, # Width in mm
|
||||||
|
data_numeric=(50 - ii) * 5 + 2, # Width in mm
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if ii < 25:
|
||||||
|
parameters.append(
|
||||||
|
PartParameter(
|
||||||
|
part=part,
|
||||||
|
template=cls.template_ionized,
|
||||||
|
data='true'
|
||||||
|
if ii % 5 == 0
|
||||||
|
else 'false', # Ionized every second part
|
||||||
|
data_numeric=1
|
||||||
|
if ii % 5 == 0
|
||||||
|
else 0, # Ionized every second part
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if ii < 15:
|
||||||
|
parameters.append(
|
||||||
|
PartParameter(
|
||||||
|
part=part,
|
||||||
|
template=cls.template_color,
|
||||||
|
data=['red', 'green', 'blue'][ii % 3], # Cycle through colors
|
||||||
|
data_numeric=None, # No numeric value for color
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk create all parameters
|
||||||
|
PartParameter.objects.bulk_create(parameters)
|
||||||
|
|
||||||
|
def test_filter_by_length(self):
|
||||||
|
"""Test basic filtering by length parameter."""
|
||||||
|
length_filters = [
|
||||||
|
('_lt', '25', 2),
|
||||||
|
('_lt', '25 mm', 2),
|
||||||
|
('_gt', '1 inch', 47),
|
||||||
|
('', '105', 1),
|
||||||
|
('_lt', '2 mile', 50),
|
||||||
|
]
|
||||||
|
|
||||||
|
for operator, value, expected_count in length_filters:
|
||||||
|
filter_name = f'parameter_{self.template_length.pk}' + operator
|
||||||
|
response = self.get(self.url, {filter_name: value}, expected_code=200).data
|
||||||
|
|
||||||
|
self.assertEqual(len(response), expected_count)
|
||||||
|
|
||||||
|
def test_filter_by_width(self):
|
||||||
|
"""Test basic filtering by width parameter."""
|
||||||
|
width_filters = [
|
||||||
|
('_lt', '102', 19),
|
||||||
|
('_lte', '102 mm', 20),
|
||||||
|
('_gte', '0.1 yards', 33),
|
||||||
|
('', '52mm', 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
for operator, value, expected_count in width_filters:
|
||||||
|
filter_name = f'parameter_{self.template_width.pk}' + operator
|
||||||
|
response = self.get(self.url, {filter_name: value}, expected_code=200).data
|
||||||
|
|
||||||
|
self.assertEqual(len(response), expected_count)
|
||||||
|
|
||||||
|
def test_filter_by_ionized(self):
|
||||||
|
"""Test filtering by ionized parameter."""
|
||||||
|
ionized_filters = [
|
||||||
|
('', 'true', 5), # Ionized parts
|
||||||
|
('', 'false', 20), # Non-ionized parts
|
||||||
|
]
|
||||||
|
|
||||||
|
for operator, value, expected_count in ionized_filters:
|
||||||
|
filter_name = f'parameter_{self.template_ionized.pk}' + operator
|
||||||
|
response = self.get(self.url, {filter_name: value}, expected_code=200).data
|
||||||
|
|
||||||
|
self.assertEqual(len(response), expected_count)
|
||||||
|
|
||||||
|
def test_filter_by_color(self):
|
||||||
|
"""Test filtering by color parameter."""
|
||||||
|
for color in ['red', 'green', 'blue']:
|
||||||
|
response = self.get(
|
||||||
|
self.url,
|
||||||
|
{f'parameter_{self.template_color.pk}': color},
|
||||||
|
expected_code=200,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertEqual(len(response), 5)
|
||||||
|
|
||||||
|
def test_filter_multiple(self):
|
||||||
|
"""Test filtering by multiple parameters."""
|
||||||
|
data = {f'parameter_{self.template_length.pk}_lt': '225'}
|
||||||
|
response = self.get(self.url, data)
|
||||||
|
self.assertEqual(len(response.data), 22)
|
||||||
|
|
||||||
|
data[f'parameter_{self.template_width.pk}_gt'] = '150'
|
||||||
|
response = self.get(self.url, data)
|
||||||
|
self.assertEqual(len(response.data), 21)
|
||||||
|
|
||||||
|
data[f'parameter_{self.template_ionized.pk}'] = 'true'
|
||||||
|
response = self.get(self.url, data)
|
||||||
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
|
for color in ['red', 'green', 'blue']:
|
||||||
|
data[f'parameter_{self.template_color.pk}'] = color
|
||||||
|
response = self.get(self.url, data)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
@ -245,6 +245,7 @@ export function usePartParameterFields({
|
|||||||
type: fieldType,
|
type: fieldType,
|
||||||
field_type: fieldType,
|
field_type: fieldType,
|
||||||
choices: fieldType === 'choice' ? choices : undefined,
|
choices: fieldType === 'choice' ? choices : undefined,
|
||||||
|
default: fieldType === 'boolean' ? 'false' : undefined,
|
||||||
adjustValue: (value: any) => {
|
adjustValue: (value: any) => {
|
||||||
// Coerce boolean value into a string (required by backend)
|
// Coerce boolean value into a string (required by backend)
|
||||||
return value.toString();
|
return value.toString();
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Group } from '@mantine/core';
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
TextInput
|
||||||
|
} from '@mantine/core';
|
||||||
import { useHover } from '@mantine/hooks';
|
import { useHover } from '@mantine/hooks';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||||
@ -14,6 +20,7 @@ import { getDetailUrl } from '@lib/functions/Navigation';
|
|||||||
import { navigateToLink } from '@lib/functions/Navigation';
|
import { navigateToLink } from '@lib/functions/Navigation';
|
||||||
import type { TableFilter } from '@lib/types/Filters';
|
import type { TableFilter } from '@lib/types/Filters';
|
||||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||||
|
import { IconCircleX } from '@tabler/icons-react';
|
||||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||||
import { useApi } from '../../contexts/ApiContext';
|
import { useApi } from '../../contexts/ApiContext';
|
||||||
import { formatDecimal } from '../../defaults/formatters';
|
import { formatDecimal } from '../../defaults/formatters';
|
||||||
@ -90,6 +97,115 @@ function ParameterCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ParameterFilter({
|
||||||
|
template,
|
||||||
|
filterValue,
|
||||||
|
setFilter,
|
||||||
|
clearFilter,
|
||||||
|
closeFilter
|
||||||
|
}: {
|
||||||
|
template: any;
|
||||||
|
filterValue?: string;
|
||||||
|
setFilter: (templateId: number, value: string, operator: string) => void;
|
||||||
|
clearFilter: (templateId: number) => void;
|
||||||
|
closeFilter: () => void;
|
||||||
|
}) {
|
||||||
|
const [operator, setOperator] = useState<string>('=');
|
||||||
|
|
||||||
|
const clearFilterButton = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
aria-label={`clear-filter-${template.name}`}
|
||||||
|
variant='transparent'
|
||||||
|
color='red'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
clearFilter(template.pk);
|
||||||
|
closeFilter();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconCircleX />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}, [clearFilter, template.pk]);
|
||||||
|
|
||||||
|
// Filter input element (depends on template type)
|
||||||
|
return useMemo(() => {
|
||||||
|
if (template.checkbox) {
|
||||||
|
setOperator('=');
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={`filter-${template.name}`}
|
||||||
|
data={[t`True`, t`False`]}
|
||||||
|
value={filterValue}
|
||||||
|
defaultValue={filterValue}
|
||||||
|
onChange={(val) => setFilter(template.pk, val ?? '', '')}
|
||||||
|
placeholder={t`Select a choice`}
|
||||||
|
rightSection={clearFilterButton}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (!!template.choices) {
|
||||||
|
setOperator('=');
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={`filter-${template.name}`}
|
||||||
|
data={template.choices
|
||||||
|
.split(',')
|
||||||
|
.map((choice: string) => choice.trim())}
|
||||||
|
value={filterValue}
|
||||||
|
defaultValue={filterValue}
|
||||||
|
onChange={(val) => setFilter(template.pk, val ?? '', '')}
|
||||||
|
placeholder={t`Select a choice`}
|
||||||
|
searchable
|
||||||
|
rightSection={clearFilterButton}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let placeholder: string = t`Enter a value`;
|
||||||
|
|
||||||
|
if (template.units) {
|
||||||
|
placeholder += ` [${template.units}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap='xs' align='left'>
|
||||||
|
<TextInput
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
setFilter(
|
||||||
|
template.pk,
|
||||||
|
event.currentTarget.value || '',
|
||||||
|
operator
|
||||||
|
);
|
||||||
|
closeFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`filter-${template.name}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
defaultValue={filterValue}
|
||||||
|
rightSection={clearFilterButton}
|
||||||
|
leftSectionWidth={75}
|
||||||
|
leftSectionProps={{
|
||||||
|
style: {
|
||||||
|
paddingRight: '10px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSection={
|
||||||
|
<SegmentedControl
|
||||||
|
defaultValue='='
|
||||||
|
value={operator}
|
||||||
|
onChange={(value: string) => setOperator(value)}
|
||||||
|
size='xs'
|
||||||
|
data={['=', '<', '>']}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [template, filterValue, setFilter, clearFilterButton, operator]);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ParametricPartTable({
|
export default function ParametricPartTable({
|
||||||
categoryId
|
categoryId
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@ -115,6 +231,61 @@ export default function ParametricPartTable({
|
|||||||
refetchOnMount: true
|
refetchOnMount: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filters against selected part parameters
|
||||||
|
const [parameterFilters, setParameterFilters] = useState<any>({});
|
||||||
|
|
||||||
|
const clearParameterFilter = useCallback(
|
||||||
|
(templateId: number) => {
|
||||||
|
const filterName = `parameter_${templateId}`;
|
||||||
|
|
||||||
|
setParameterFilters((prev: any) => {
|
||||||
|
const newFilters = { ...prev };
|
||||||
|
Object.keys(newFilters).forEach((key: string) => {
|
||||||
|
// Remove any filters that match the template ID
|
||||||
|
if (key.startsWith(filterName)) {
|
||||||
|
delete newFilters[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newFilters;
|
||||||
|
});
|
||||||
|
|
||||||
|
table.refreshTable();
|
||||||
|
},
|
||||||
|
[setParameterFilters, table.refreshTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addParameterFilter = useCallback(
|
||||||
|
(templateId: number, value: string, operator: string) => {
|
||||||
|
// First, clear any existing filters for this template
|
||||||
|
clearParameterFilter(templateId);
|
||||||
|
|
||||||
|
// Map the operator to a more API-friendly format
|
||||||
|
const operations: Record<string, string> = {
|
||||||
|
'=': '',
|
||||||
|
'<': 'lt',
|
||||||
|
'>': 'gt',
|
||||||
|
'<=': 'lte',
|
||||||
|
'>=': 'gte'
|
||||||
|
};
|
||||||
|
|
||||||
|
const op = operations[operator] ?? '';
|
||||||
|
let filterName = `parameter_${templateId}`;
|
||||||
|
|
||||||
|
if (op) {
|
||||||
|
filterName += `_${op}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setParameterFilters((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
[filterName]: value?.trim() ?? ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
table.refreshTable();
|
||||||
|
},
|
||||||
|
[setParameterFilters, clearParameterFilter, table.refreshTable]
|
||||||
|
);
|
||||||
|
|
||||||
const [selectedPart, setSelectedPart] = useState<number>(0);
|
const [selectedPart, setSelectedPart] = useState<number>(0);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
|
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
|
||||||
const [selectedParameter, setSelectedParameter] = useState<number>(0);
|
const [selectedParameter, setSelectedParameter] = useState<number>(0);
|
||||||
@ -186,6 +357,10 @@ export default function ParametricPartTable({
|
|||||||
title += ` [${template.units}]`;
|
title += ` [${template.units}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterKey = Object.keys(parameterFilters).find((key: string) =>
|
||||||
|
key.startsWith(`parameter_${template.pk}`)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessor: `parameter_${template.pk}`,
|
accessor: `parameter_${template.pk}`,
|
||||||
title: title,
|
title: title,
|
||||||
@ -199,10 +374,20 @@ export default function ParametricPartTable({
|
|||||||
template={template}
|
template={template}
|
||||||
canEdit={user.hasChangeRole(UserRoles.part)}
|
canEdit={user.hasChangeRole(UserRoles.part)}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
filtering: !!filterKey,
|
||||||
|
filter: ({ close }: { close: () => void }) => (
|
||||||
|
<ParameterFilter
|
||||||
|
template={template}
|
||||||
|
filterValue={filterKey && parameterFilters[filterKey]}
|
||||||
|
setFilter={addParameterFilter}
|
||||||
|
clearFilter={clearParameterFilter}
|
||||||
|
closeFilter={close}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [user, categoryParameters.data]);
|
}, [user, categoryParameters.data, parameterFilters]);
|
||||||
|
|
||||||
const onParameterClick = useCallback((template: number, part: any) => {
|
const onParameterClick = useCallback((template: number, part: any) => {
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
@ -275,7 +460,8 @@ export default function ParametricPartTable({
|
|||||||
category: categoryId,
|
category: categoryId,
|
||||||
cascade: true,
|
cascade: true,
|
||||||
category_detail: true,
|
category_detail: true,
|
||||||
parameters: true
|
parameters: true,
|
||||||
|
...parameterFilters
|
||||||
},
|
},
|
||||||
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
||||||
cancelEvent(event);
|
cancelEvent(event);
|
||||||
|
@ -407,6 +407,41 @@ test('Parts - Parameters', async ({ browser }) => {
|
|||||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Parts - Parameter Filtering', async ({ browser }) => {
|
||||||
|
const page = await doCachedLogin(browser, { url: 'part/' });
|
||||||
|
|
||||||
|
await loadTab(page, 'Part Parameters');
|
||||||
|
await clearTableFilters(page);
|
||||||
|
|
||||||
|
// All parts should be available (no filters applied)
|
||||||
|
await page.getByText('/ 425').waitFor();
|
||||||
|
|
||||||
|
const clickOnParamFilter = async (name: string) => {
|
||||||
|
const button = await page
|
||||||
|
.getByRole('button', { name: `${name} Not sorted` })
|
||||||
|
.getByRole('button')
|
||||||
|
.first();
|
||||||
|
await button.scrollIntoViewIfNeeded();
|
||||||
|
await button.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearParamFilter = async (name: string) => {
|
||||||
|
await clickOnParamFilter(name);
|
||||||
|
await page.getByLabel(`clear-filter-${name}`).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Let's filter by color
|
||||||
|
await clickOnParamFilter('Color');
|
||||||
|
await page.getByRole('option', { name: 'Red' }).click();
|
||||||
|
|
||||||
|
// Only 10 parts available
|
||||||
|
await page.getByText('/ 10').waitFor();
|
||||||
|
|
||||||
|
// Reset the filter
|
||||||
|
await clearParamFilter('Color');
|
||||||
|
await page.getByText('/ 425').waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
test('Parts - Notes', async ({ browser }) => {
|
test('Parts - Notes', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'part/69/notes' });
|
const page = await doCachedLogin(browser, { url: 'part/69/notes' });
|
||||||
|
|
||||||
|
9
tasks.py
9
tasks.py
@ -536,6 +536,14 @@ def setup_dev(c, tests=False):
|
|||||||
|
|
||||||
|
|
||||||
# Setup / maintenance tasks
|
# Setup / maintenance tasks
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def shell(c):
|
||||||
|
"""Launch a Django shell."""
|
||||||
|
manage(c, 'shell', pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def superuser(c):
|
def superuser(c):
|
||||||
"""Create a superuser/admin account for the database."""
|
"""Create a superuser/admin account for the database."""
|
||||||
@ -1831,6 +1839,7 @@ development = Collection(
|
|||||||
server,
|
server,
|
||||||
setup_dev,
|
setup_dev,
|
||||||
setup_test,
|
setup_test,
|
||||||
|
shell,
|
||||||
test,
|
test,
|
||||||
test_translations,
|
test_translations,
|
||||||
translate,
|
translate,
|
||||||
|
Reference in New Issue
Block a user