Param filters 2 (#9749)
* Filter updates - Split code - Allow multiple simultaneous filters against a given parameter - Bug fixes * Refactoring * Cleanup * fix for operator selection * Backend fix * Additional filtering options * Updated documentation * Impove filtering logic * Tweak playwright tests * Remove debug statements * Tweak for login test
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 34 KiB |
BIN
docs/docs/assets/images/part/multiple_filters_same_param.png
Normal file
After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
@ -82,6 +82,12 @@ Each parameter column indicates whether a filter is currently applied:
|
|||||||
|
|
||||||
{{ image("part/multiple_param_filters.png", "Multiple Parameter Filters") }}
|
{{ image("part/multiple_param_filters.png", "Multiple Parameter Filters") }}
|
||||||
|
|
||||||
|
#### Multiple Filters Against the Same Parameter
|
||||||
|
|
||||||
|
It is possible to apply multiple filters against the same parameter. For example, you can filter for parts with a *Resistance* parameter greater than 10kΩ and less than 100kΩ by adding two filters for the *Resistance* parameter:
|
||||||
|
|
||||||
|
{{ image("part/multiple_filters_same_param.png", "Multiple Filters on Same Parameter") }}
|
||||||
|
|
||||||
#### Unit-Aware Filtering
|
#### 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.
|
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.
|
||||||
@ -90,12 +96,23 @@ For example, to show all parts with a *Resistance* parameter of greater than 10k
|
|||||||
|
|
||||||
{{ image("part/filter_with_unit.png", "Unit Aware Filters") }}
|
{{ image("part/filter_with_unit.png", "Unit Aware Filters") }}
|
||||||
|
|
||||||
#### Remove Filter
|
#### Removing Filters
|
||||||
|
|
||||||
To remove a filter against a given parameter, click on the {{ icon("circle-x", color='red') }} button associated with that 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") }}
|
{{ image("part/remove_param_filter.png", "Remove Parameter Filter") }}
|
||||||
|
|
||||||
|
#### Available Filter Operators
|
||||||
|
|
||||||
|
The following filter operators are available for parameter filtering:
|
||||||
|
|
||||||
|
- `=`: Equal to
|
||||||
|
- `>`: Greater than
|
||||||
|
- `>=`: Greater than or equal to
|
||||||
|
- `<`: Less than
|
||||||
|
- `<=`: Less than or equal to
|
||||||
|
- `!=`: Not equal to
|
||||||
|
- `~`: Contains (for text parameters)
|
||||||
|
|
||||||
## Parameter Units
|
## Parameter Units
|
||||||
|
|
||||||
|
@ -15,7 +15,6 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
from taggit.serializers import TagListSerializerField
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
import common.models as common_models
|
import common.models as common_models
|
||||||
import common.settings
|
|
||||||
import common.validators
|
import common.validators
|
||||||
import generic.states.custom
|
import generic.states.custom
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
@ -142,8 +141,9 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
|||||||
|
|
||||||
- It is overridden by an environment variable.
|
- It is overridden by an environment variable.
|
||||||
"""
|
"""
|
||||||
overrides = common.settings.global_setting_overrides()
|
from common.settings import global_setting_overrides
|
||||||
|
|
||||||
|
overrides = global_setting_overrides()
|
||||||
return obj.key in overrides
|
return obj.key in overrides
|
||||||
|
|
||||||
|
|
||||||
|
@ -1358,8 +1358,9 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
|||||||
- <value> is the value to filter against.
|
- <value> is the value to filter against.
|
||||||
"""
|
"""
|
||||||
# Allowed lookup operations for parameter values
|
# Allowed lookup operations for parameter values
|
||||||
operations = '|'.join(['gt', 'lt', 'gte', 'lte'])
|
operators = '|'.join(part.filters.PARAMETER_FILTER_OPERATORS)
|
||||||
regex_pattern = rf'^parameter_(\d+)(_({operations}))?$'
|
|
||||||
|
regex_pattern = rf'^parameter_(\d+)(_({operators}))?$'
|
||||||
|
|
||||||
for param in self.request.query_params:
|
for param in self.request.query_params:
|
||||||
result = re.match(regex_pattern, param)
|
result = re.match(regex_pattern, param)
|
||||||
@ -1370,9 +1371,6 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
|||||||
template_id = result.group(1)
|
template_id = result.group(1)
|
||||||
operator = result.group(3) or ''
|
operator = result.group(3) or ''
|
||||||
|
|
||||||
if operator:
|
|
||||||
operator = '__' + operator
|
|
||||||
|
|
||||||
value = self.request.query_params.get(param, None)
|
value = self.request.query_params.get(param, None)
|
||||||
|
|
||||||
queryset = part.filters.filter_by_parameter(
|
queryset = part.filters.filter_by_parameter(
|
||||||
|
@ -326,6 +326,10 @@ def annotate_sub_categories():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
"""A list of valid operators for filtering part parameters."""
|
||||||
|
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']
|
||||||
|
|
||||||
|
|
||||||
def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''):
|
def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''):
|
||||||
"""Filter the given queryset by a given template parameter.
|
"""Filter the given queryset by a given template parameter.
|
||||||
|
|
||||||
@ -340,6 +344,9 @@ 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
|
||||||
"""
|
"""
|
||||||
|
if func and func not in PARAMETER_FILTER_OPERATORS:
|
||||||
|
raise ValueError(f'Invalid parameter filter function supplied: {func}.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
template = part.models.PartParameterTemplate.objects.get(pk=template_id)
|
template = part.models.PartParameterTemplate.objects.get(pk=template_id)
|
||||||
except (ValueError, part.models.PartParameterTemplate.DoesNotExist):
|
except (ValueError, part.models.PartParameterTemplate.DoesNotExist):
|
||||||
@ -372,27 +379,49 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''):
|
|||||||
# The value cannot be converted - return an empty queryset
|
# The value cannot be converted - return an empty queryset
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
# Special handling for the "not equal" operator
|
||||||
|
if func == 'ne':
|
||||||
|
invert = True
|
||||||
|
func = ''
|
||||||
|
else:
|
||||||
|
invert = False
|
||||||
|
|
||||||
|
# Some filters are only applicable to string values
|
||||||
|
text_only = any([func in ['icontains'], value_numeric is None])
|
||||||
|
|
||||||
|
# Ensure the function starts with a double underscore
|
||||||
|
if func and not func.startswith('__'):
|
||||||
|
func = f'__{func}'
|
||||||
|
|
||||||
# Query for 'numeric' value - this has priority over 'string' value
|
# Query for 'numeric' value - this has priority over 'string' value
|
||||||
q1 = Q(**{
|
data_numeric = {
|
||||||
'parameters__template': template,
|
'parameters__template': template,
|
||||||
'parameters__data_numeric__isnull': False,
|
'parameters__data_numeric__isnull': False,
|
||||||
f'parameters__data_numeric{func}': value_numeric,
|
f'parameters__data_numeric{func}': value_numeric,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
query_numeric = Q(**data_numeric)
|
||||||
|
|
||||||
# Query for 'string' value
|
# Query for 'string' value
|
||||||
q2 = Q(**{
|
data_text = {
|
||||||
'parameters__template': template,
|
'parameters__template': template,
|
||||||
'parameters__data_numeric__isnull': True,
|
|
||||||
f'parameters__data{func}': str(value),
|
f'parameters__data{func}': str(value),
|
||||||
})
|
}
|
||||||
|
|
||||||
if value_numeric is not None:
|
if not text_only:
|
||||||
queryset = queryset.filter(q1 | q2).distinct()
|
data_text['parameters__data_numeric__isnull'] = True
|
||||||
|
|
||||||
|
query_text = Q(**data_text)
|
||||||
|
|
||||||
|
# Combine the queries based on whether we are filtering by text or numeric value
|
||||||
|
q = query_text if text_only else query_text | query_numeric
|
||||||
|
|
||||||
|
# Special handling for the '__ne' (not equal) operator
|
||||||
|
# In this case, we want the *opposite* of the above queries
|
||||||
|
if invert:
|
||||||
|
return queryset.exclude(q).distinct()
|
||||||
else:
|
else:
|
||||||
# If the value is not numeric, we only filter by the string value
|
return queryset.filter(q).distinct()
|
||||||
queryset = queryset.filter(q2).distinct()
|
|
||||||
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
def order_by_parameter(queryset, template_id: int, ascending=True):
|
def order_by_parameter(queryset, template_id: int, ascending=True):
|
||||||
|
@ -1,11 +1,5 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import {
|
import { Group } from '@mantine/core';
|
||||||
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';
|
||||||
@ -20,7 +14,6 @@ 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';
|
||||||
@ -35,6 +28,10 @@ import type { TableColumn } from '../Column';
|
|||||||
import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { TableHoverCard } from '../TableHoverCard';
|
import { TableHoverCard } from '../TableHoverCard';
|
||||||
|
import {
|
||||||
|
PARAMETER_FILTER_OPERATORS,
|
||||||
|
ParameterFilter
|
||||||
|
} from './ParametricPartTableFilters';
|
||||||
|
|
||||||
// Render an individual parameter cell
|
// Render an individual parameter cell
|
||||||
function ParameterCell({
|
function ParameterCell({
|
||||||
@ -97,115 +94,6 @@ 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<{
|
||||||
@ -231,61 +119,115 @@ export default function ParametricPartTable({
|
|||||||
refetchOnMount: true
|
refetchOnMount: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filters against selected part parameters
|
/* Store filters against selected part parameters.
|
||||||
|
* These are stored in the format:
|
||||||
|
* {
|
||||||
|
* parameter_1: {
|
||||||
|
* '=': 'value1',
|
||||||
|
* '<': 'value2',
|
||||||
|
* ...
|
||||||
|
* },
|
||||||
|
* parameter_2: {
|
||||||
|
* '=': 'value3',
|
||||||
|
* },
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Which allows multiple filters to be applied against each parameter template.
|
||||||
|
*/
|
||||||
const [parameterFilters, setParameterFilters] = useState<any>({});
|
const [parameterFilters, setParameterFilters] = useState<any>({});
|
||||||
|
|
||||||
|
/* Remove filters for a specific parameter template
|
||||||
|
* - If no operator is specified, remove all filters for this template
|
||||||
|
* - If an operator is specified, remove filters for that operator only
|
||||||
|
*/
|
||||||
const clearParameterFilter = useCallback(
|
const clearParameterFilter = useCallback(
|
||||||
(templateId: number) => {
|
(templateId: number, operator?: string) => {
|
||||||
const filterName = `parameter_${templateId}`;
|
const filterName = `parameter_${templateId}`;
|
||||||
|
|
||||||
|
if (!operator) {
|
||||||
|
// If no operator is specified, remove all filters for this template
|
||||||
setParameterFilters((prev: any) => {
|
setParameterFilters((prev: any) => {
|
||||||
const newFilters = { ...prev };
|
const newFilters = { ...prev };
|
||||||
Object.keys(newFilters).forEach((key: string) => {
|
|
||||||
// Remove any filters that match the template ID
|
// Remove any filters that match the template ID
|
||||||
if (key.startsWith(filterName)) {
|
Object.keys(newFilters).forEach((key: string) => {
|
||||||
|
if (key == filterName) {
|
||||||
delete newFilters[key];
|
delete newFilters[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return newFilters;
|
return newFilters;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator is specified, so we remove filters for that operator only
|
||||||
|
setParameterFilters((prev: any) => {
|
||||||
|
const filters = { ...prev };
|
||||||
|
|
||||||
|
const paramFilters = filters[filterName] || {};
|
||||||
|
|
||||||
|
if (paramFilters[operator]) {
|
||||||
|
// Remove the specific operator filter
|
||||||
|
delete paramFilters[operator];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filters,
|
||||||
|
[filterName]: paramFilters
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
table.refreshTable();
|
table.refreshTable();
|
||||||
},
|
},
|
||||||
[setParameterFilters, table.refreshTable]
|
[setParameterFilters, table.refreshTable]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add (or update) a filter for a specific parameter template.
|
||||||
|
* @param templateId - The ID of the parameter template to filter on.
|
||||||
|
* @param value - The value to filter by.
|
||||||
|
* @param operator - The operator to use for filtering (e.g., '=', '<', '>', etc.).
|
||||||
|
*/
|
||||||
const addParameterFilter = useCallback(
|
const addParameterFilter = useCallback(
|
||||||
(templateId: number, value: string, operator: string) => {
|
(templateId: number, value: string, operator: string) => {
|
||||||
// First, clear any existing filters for this template
|
const filterName = `parameter_${templateId}`;
|
||||||
clearParameterFilter(templateId);
|
|
||||||
|
|
||||||
// Map the operator to a more API-friendly format
|
setParameterFilters((prev: any) => {
|
||||||
const operations: Record<string, string> = {
|
const filters = { ...prev };
|
||||||
'=': '',
|
const paramFilters = filters[filterName] || {};
|
||||||
'<': 'lt',
|
|
||||||
'>': 'gt',
|
paramFilters[operator] = value;
|
||||||
'<=': 'lte',
|
|
||||||
'>=': 'gte'
|
return {
|
||||||
|
...filters,
|
||||||
|
[filterName]: paramFilters
|
||||||
};
|
};
|
||||||
|
});
|
||||||
const op = operations[operator] ?? '';
|
|
||||||
let filterName = `parameter_${templateId}`;
|
|
||||||
|
|
||||||
if (op) {
|
|
||||||
filterName += `_${op}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setParameterFilters((prev: any) => ({
|
|
||||||
...prev,
|
|
||||||
[filterName]: value?.trim() ?? ''
|
|
||||||
}));
|
|
||||||
|
|
||||||
table.refreshTable();
|
table.refreshTable();
|
||||||
},
|
},
|
||||||
[setParameterFilters, clearParameterFilter, table.refreshTable]
|
[setParameterFilters, clearParameterFilter, table.refreshTable]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Construct the query filters for the table based on the parameter filters
|
||||||
|
const parametricQueryFilters = useMemo(() => {
|
||||||
|
const filters: Record<string, string> = {};
|
||||||
|
|
||||||
|
Object.keys(parameterFilters).forEach((key: string) => {
|
||||||
|
const paramFilters: any = parameterFilters[key];
|
||||||
|
|
||||||
|
Object.keys(paramFilters).forEach((operator: string) => {
|
||||||
|
const name = `${key}${PARAMETER_FILTER_OPERATORS[operator] || ''}`;
|
||||||
|
const value = paramFilters[operator];
|
||||||
|
|
||||||
|
filters[name] = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}, [parameterFilters]);
|
||||||
|
|
||||||
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);
|
||||||
@ -357,9 +299,7 @@ export default function ParametricPartTable({
|
|||||||
title += ` [${template.units}]`;
|
title += ` [${template.units}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterKey = Object.keys(parameterFilters).find((key: string) =>
|
const filters = parameterFilters[`parameter_${template.pk}`] || {};
|
||||||
key.startsWith(`parameter_${template.pk}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessor: `parameter_${template.pk}`,
|
accessor: `parameter_${template.pk}`,
|
||||||
@ -375,16 +315,18 @@ export default function ParametricPartTable({
|
|||||||
canEdit={user.hasChangeRole(UserRoles.part)}
|
canEdit={user.hasChangeRole(UserRoles.part)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
filtering: !!filterKey,
|
filtering: Object.keys(filters).length > 0,
|
||||||
filter: ({ close }: { close: () => void }) => (
|
filter: ({ close }: { close: () => void }) => {
|
||||||
|
return (
|
||||||
<ParameterFilter
|
<ParameterFilter
|
||||||
template={template}
|
template={template}
|
||||||
filterValue={filterKey && parameterFilters[filterKey]}
|
filters={parameterFilters[`parameter_${template.pk}`] || {}}
|
||||||
setFilter={addParameterFilter}
|
setFilter={addParameterFilter}
|
||||||
clearFilter={clearParameterFilter}
|
clearFilter={clearParameterFilter}
|
||||||
closeFilter={close}
|
closeFilter={close}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [user, categoryParameters.data, parameterFilters]);
|
}, [user, categoryParameters.data, parameterFilters]);
|
||||||
@ -461,7 +403,7 @@ export default function ParametricPartTable({
|
|||||||
cascade: true,
|
cascade: true,
|
||||||
category_detail: true,
|
category_detail: true,
|
||||||
parameters: true,
|
parameters: true,
|
||||||
...parameterFilters
|
...parametricQueryFilters
|
||||||
},
|
},
|
||||||
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
||||||
cancelEvent(event);
|
cancelEvent(event);
|
||||||
|
218
src/frontend/src/tables/part/ParametricPartTableFilters.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconCircleX } from '@tabler/icons-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
// Define set of allowed operators for parameter filters
|
||||||
|
export const PARAMETER_FILTER_OPERATORS: Record<string, string> = {
|
||||||
|
'=': '',
|
||||||
|
'>': '_gt',
|
||||||
|
'>=': '_gte',
|
||||||
|
'<': '_lt',
|
||||||
|
'<=': '_lte',
|
||||||
|
'!=': '_ne',
|
||||||
|
'~': '_icontains'
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParameterFilterProps = {
|
||||||
|
template: any;
|
||||||
|
filters: any;
|
||||||
|
setFilter: (templateId: number, value: string, operator: string) => void;
|
||||||
|
clearFilter: (templateId: number, operator?: string) => void;
|
||||||
|
closeFilter: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ClearFilterButton({
|
||||||
|
props,
|
||||||
|
operator
|
||||||
|
}: {
|
||||||
|
props: ParameterFilterProps;
|
||||||
|
operator?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
aria-label={`clear-filter-${props.template.name}`}
|
||||||
|
variant='transparent'
|
||||||
|
color='red'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
props.clearFilter(props.template.pk, operator ?? '');
|
||||||
|
props.closeFilter();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconCircleX />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI element for viewing and changing boolean filter associated with a given parameter template
|
||||||
|
*/
|
||||||
|
function BooleanParameterFilter(props: ParameterFilterProps) {
|
||||||
|
const filterValue = useMemo(() => {
|
||||||
|
return props.filters['='] ?? '';
|
||||||
|
}, [props.filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={`filter-${props.template.name}`}
|
||||||
|
data={[
|
||||||
|
{ value: 'true', label: t`True` },
|
||||||
|
{ value: 'false', label: t`False` }
|
||||||
|
]}
|
||||||
|
value={filterValue}
|
||||||
|
defaultValue={filterValue}
|
||||||
|
onChange={(val) => props.setFilter(props.template.pk, val ?? '', '=')}
|
||||||
|
placeholder={t`Select a choice`}
|
||||||
|
rightSection={<ClearFilterButton props={props} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* UI element for viewing and changing choice filter associated with a given parameter template.
|
||||||
|
* In this case, the template defines a set of choices that can be selected.
|
||||||
|
*/
|
||||||
|
function ChoiceParameterFilter(props: ParameterFilterProps) {
|
||||||
|
const filterValue = useMemo(() => {
|
||||||
|
return props.filters['='] ?? '';
|
||||||
|
}, [props.filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={`filter-${props.template.name}`}
|
||||||
|
data={props.template.choices
|
||||||
|
.split(',')
|
||||||
|
.map((choice: string) => choice.trim())}
|
||||||
|
value={filterValue}
|
||||||
|
defaultValue={filterValue}
|
||||||
|
onChange={(val) => props.setFilter(props.template.pk, val ?? '', '=')}
|
||||||
|
placeholder={t`Select a choice`}
|
||||||
|
searchable
|
||||||
|
rightSection={<ClearFilterButton props={props} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericFilterRow({
|
||||||
|
props,
|
||||||
|
value,
|
||||||
|
operator,
|
||||||
|
readonly
|
||||||
|
}: {
|
||||||
|
props: ParameterFilterProps;
|
||||||
|
value: string;
|
||||||
|
operator: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}) {
|
||||||
|
const placeholder: string = useMemo(() => {
|
||||||
|
let placeholder = t`Enter a value`;
|
||||||
|
|
||||||
|
if (props.template.units) {
|
||||||
|
placeholder += ` [${props.template.units}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder;
|
||||||
|
}, [props.template.units]);
|
||||||
|
|
||||||
|
const [op, setOp] = useState<string>(operator);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOp(operator);
|
||||||
|
}, [operator]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap='xs' wrap='nowrap'>
|
||||||
|
<div onMouseDown={(e) => e.stopPropagation()} style={{ width: 75 }}>
|
||||||
|
<Select
|
||||||
|
onClick={(event) => {
|
||||||
|
event?.stopPropagation();
|
||||||
|
}}
|
||||||
|
aria-label={`filter-${props.template.name}-operator`}
|
||||||
|
data={Object.keys(PARAMETER_FILTER_OPERATORS)}
|
||||||
|
value={op}
|
||||||
|
searchable={false}
|
||||||
|
clearable={false}
|
||||||
|
defaultValue={'='}
|
||||||
|
onChange={(value) => {
|
||||||
|
setOp(value ?? '=');
|
||||||
|
}}
|
||||||
|
size='sm'
|
||||||
|
disabled={readonly}
|
||||||
|
width={75}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextInput
|
||||||
|
aria-label={`filter-${props.template.name}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
defaultValue={value}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
props.setFilter(
|
||||||
|
props.template.pk,
|
||||||
|
event.currentTarget.value || '',
|
||||||
|
op
|
||||||
|
);
|
||||||
|
props.closeFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rightSection={
|
||||||
|
readonly && <ClearFilterButton props={props} operator={op} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* In this case, the template is generic and does not have a specific type.
|
||||||
|
* Here, the user can apply multiple filter types (e.g., '=', '<', '>')
|
||||||
|
*/
|
||||||
|
function GenericParameterFilter(props: ParameterFilterProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap='xs'>
|
||||||
|
{/* Render a row for each operator defined in the filters object */}
|
||||||
|
{Object.keys(props.filters).map((operator) => {
|
||||||
|
return (
|
||||||
|
<GenericFilterRow
|
||||||
|
props={props}
|
||||||
|
value={props.filters[operator] ?? ''}
|
||||||
|
operator={operator}
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Divider />
|
||||||
|
{/* Render an empty row for adding a new filter */}
|
||||||
|
<GenericFilterRow props={props} value='' operator='=' />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI element for viewing and changing filter(s) associated with a given parameter template
|
||||||
|
* @param template - The parameter template object
|
||||||
|
* @param filters - The current filters applied to the table
|
||||||
|
* @param setFilter - Function to set a filter for the template
|
||||||
|
* @param clearFilter - Function to clear the filter for the template
|
||||||
|
* @param closeFilter - Function to close the filter UI
|
||||||
|
*/
|
||||||
|
export function ParameterFilter(props: ParameterFilterProps) {
|
||||||
|
// Filter input element (depends on template type)
|
||||||
|
return useMemo(() => {
|
||||||
|
if (props.template.checkbox) {
|
||||||
|
return <BooleanParameterFilter {...props} />;
|
||||||
|
} else if (!!props.template.choices) {
|
||||||
|
return <ChoiceParameterFilter {...props} />;
|
||||||
|
} else {
|
||||||
|
return <GenericParameterFilter {...props} />;
|
||||||
|
}
|
||||||
|
}, [props]);
|
||||||
|
}
|
@ -238,7 +238,9 @@ test('Build Order - Allocation', async ({ browser }) => {
|
|||||||
// Expand this row
|
// Expand this row
|
||||||
await cell.click();
|
await cell.click();
|
||||||
await page.getByRole('cell', { name: '2022-4-27', exact: true }).waitFor();
|
await page.getByRole('cell', { name: '2022-4-27', exact: true }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Reel Storage', exact: true }).waitFor();
|
await page
|
||||||
|
.getByRole('cell', { name: 'Electronics Lab/Reel Storage', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
// Navigate to the "Incomplete Outputs" tab
|
// Navigate to the "Incomplete Outputs" tab
|
||||||
await loadTab(page, 'Incomplete Outputs');
|
await loadTab(page, 'Incomplete Outputs');
|
||||||
|
@ -199,7 +199,9 @@ test('Parts - Allocations', async ({ browser }) => {
|
|||||||
// Expand allocations against BO0001
|
// Expand allocations against BO0001
|
||||||
await build_order_cell.click();
|
await build_order_cell.click();
|
||||||
await page.getByRole('cell', { name: '# 3', exact: true }).waitFor();
|
await page.getByRole('cell', { name: '# 3', exact: true }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Room 101', exact: true }).waitFor();
|
await page
|
||||||
|
.getByRole('cell', { name: 'Factory/Office Block/Room 101', exact: true })
|
||||||
|
.waitFor();
|
||||||
await build_order_cell.click();
|
await build_order_cell.click();
|
||||||
|
|
||||||
// Check row options for BO0001
|
// Check row options for BO0001
|
||||||
|
@ -46,9 +46,12 @@ test('Login - Failures', async ({ page }) => {
|
|||||||
|
|
||||||
test('Login - Change Password', async ({ page }) => {
|
test('Login - Change Password', async ({ page }) => {
|
||||||
await doLogin(page, 'noaccess', 'youshallnotpass');
|
await doLogin(page, 'noaccess', 'youshallnotpass');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Navigate to the 'change password' page
|
// Navigate to the 'change password' page
|
||||||
await navigate(page, 'settings/user/account');
|
await navigate(page, 'settings/user/account');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await page.getByLabel('action-menu-account-actions').click();
|
await page.getByLabel('action-menu-account-actions').click();
|
||||||
await page.getByLabel('action-menu-account-actions-change-password').click();
|
await page.getByLabel('action-menu-account-actions-change-password').click();
|
||||||
|
|
||||||
|