mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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