mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-12 18:15: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.
|
||||
|
||||
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