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. | ||||
|  | ||||
| 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: | ||||
|  | ||||
| @@ -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") }} | ||||
|  | ||||
| ### 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 | ||||
|  | ||||
| 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. | ||||
|  | ||||
| ### Parameter Sorting | ||||
| ### Parameter Unit Sorting | ||||
|  | ||||
| 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.exceptions import ValidationError | ||||
|  | ||||
| import build.admin | ||||
| import build.serializers | ||||
| import common.models | ||||
| import part.models as part_models | ||||
|   | ||||
| @@ -1339,15 +1339,51 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI): | ||||
|                     pass | ||||
|  | ||||
|         queryset = self.filter_parametric_data(queryset) | ||||
|         queryset = self.order_by_parameter(queryset) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     def filter_parametric_data(self, queryset): | ||||
|         """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 | ||||
|         - e.g. '&ordering=param_<id>' where <id> specifies the PartParameterTemplate | ||||
|         - 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://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 | ||||
| @@ -38,6 +33,8 @@ from django.db.models.functions import Coalesce | ||||
|  | ||||
| from sql_util.utils import SubquerySum | ||||
|  | ||||
| import InvenTree.conversion | ||||
| import InvenTree.helpers | ||||
| import part.models | ||||
| import stock.models | ||||
| from build.status_codes import BuildStatusGroups | ||||
| @@ -343,7 +340,58 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): | ||||
|     Returns: | ||||
|         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 | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -445,3 +445,177 @@ class PartParameterTest(InvenTreeAPITestCase): | ||||
|         for idx, expected in expectation.items(): | ||||
|             actual = get_param_value(response, template.pk, idx) | ||||
|             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, | ||||
|         field_type: fieldType, | ||||
|         choices: fieldType === 'choice' ? choices : undefined, | ||||
|         default: fieldType === 'boolean' ? 'false' : undefined, | ||||
|         adjustValue: (value: any) => { | ||||
|           // Coerce boolean value into a string (required by backend) | ||||
|           return value.toString(); | ||||
|   | ||||
| @@ -1,5 +1,11 @@ | ||||
| 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 { useQuery } from '@tanstack/react-query'; | ||||
| 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 type { TableFilter } from '@lib/types/Filters'; | ||||
| import type { ApiFormFieldSet } from '@lib/types/Forms'; | ||||
| import { IconCircleX } from '@tabler/icons-react'; | ||||
| import { YesNoButton } from '../../components/buttons/YesNoButton'; | ||||
| import { useApi } from '../../contexts/ApiContext'; | ||||
| 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({ | ||||
|   categoryId | ||||
| }: Readonly<{ | ||||
| @@ -115,6 +231,61 @@ export default function ParametricPartTable({ | ||||
|     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 [selectedTemplate, setSelectedTemplate] = useState<number>(0); | ||||
|   const [selectedParameter, setSelectedParameter] = useState<number>(0); | ||||
| @@ -186,6 +357,10 @@ export default function ParametricPartTable({ | ||||
|         title += ` [${template.units}]`; | ||||
|       } | ||||
|  | ||||
|       const filterKey = Object.keys(parameterFilters).find((key: string) => | ||||
|         key.startsWith(`parameter_${template.pk}`) | ||||
|       ); | ||||
|  | ||||
|       return { | ||||
|         accessor: `parameter_${template.pk}`, | ||||
|         title: title, | ||||
| @@ -199,10 +374,20 @@ export default function ParametricPartTable({ | ||||
|             template={template} | ||||
|             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) => { | ||||
|     setSelectedTemplate(template); | ||||
| @@ -275,7 +460,8 @@ export default function ParametricPartTable({ | ||||
|             category: categoryId, | ||||
|             cascade: true, | ||||
|             category_detail: true, | ||||
|             parameters: true | ||||
|             parameters: true, | ||||
|             ...parameterFilters | ||||
|           }, | ||||
|           onCellClick: ({ event, record, index, column, columnIndex }) => { | ||||
|             cancelEvent(event); | ||||
|   | ||||
| @@ -407,6 +407,41 @@ test('Parts - Parameters', async ({ browser }) => { | ||||
|   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 }) => { | ||||
|   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 | ||||
|  | ||||
|  | ||||
| @task | ||||
| def shell(c): | ||||
|     """Launch a Django shell.""" | ||||
|     manage(c, 'shell', pty=True) | ||||
|  | ||||
|  | ||||
| @task | ||||
| def superuser(c): | ||||
|     """Create a superuser/admin account for the database.""" | ||||
| @@ -1831,6 +1839,7 @@ development = Collection( | ||||
|     server, | ||||
|     setup_dev, | ||||
|     setup_test, | ||||
|     shell, | ||||
|     test, | ||||
|     test_translations, | ||||
|     translate, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user