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") }}
|
||||
|
||||
#### 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
|
||||
|
||||
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") }}
|
||||
|
||||
#### 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:
|
||||
|
||||
{{ 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
|
||||
|
||||
|
@ -15,7 +15,6 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.models as common_models
|
||||
import common.settings
|
||||
import common.validators
|
||||
import generic.states.custom
|
||||
from importer.registry import register_importer
|
||||
@ -142,8 +141,9 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
|
@ -1358,8 +1358,9 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
||||
- <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}))?$'
|
||||
operators = '|'.join(part.filters.PARAMETER_FILTER_OPERATORS)
|
||||
|
||||
regex_pattern = rf'^parameter_(\d+)(_({operators}))?$'
|
||||
|
||||
for param in self.request.query_params:
|
||||
result = re.match(regex_pattern, param)
|
||||
@ -1370,9 +1371,6 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
||||
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(
|
||||
|
@ -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 = ''):
|
||||
"""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:
|
||||
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:
|
||||
template = part.models.PartParameterTemplate.objects.get(pk=template_id)
|
||||
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
|
||||
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
|
||||
q1 = Q(**{
|
||||
data_numeric = {
|
||||
'parameters__template': template,
|
||||
'parameters__data_numeric__isnull': False,
|
||||
f'parameters__data_numeric{func}': value_numeric,
|
||||
})
|
||||
}
|
||||
|
||||
query_numeric = Q(**data_numeric)
|
||||
|
||||
# Query for 'string' value
|
||||
q2 = Q(**{
|
||||
data_text = {
|
||||
'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()
|
||||
if not text_only:
|
||||
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:
|
||||
# If the value is not numeric, we only filter by the string value
|
||||
queryset = queryset.filter(q2).distinct()
|
||||
|
||||
return queryset
|
||||
return queryset.filter(q).distinct()
|
||||
|
||||
|
||||
def order_by_parameter(queryset, template_id: int, ascending=True):
|
||||
|
@ -1,11 +1,5 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
TextInput
|
||||
} from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 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';
|
||||
@ -35,6 +28,10 @@ import type { TableColumn } from '../Column';
|
||||
import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { TableHoverCard } from '../TableHoverCard';
|
||||
import {
|
||||
PARAMETER_FILTER_OPERATORS,
|
||||
ParameterFilter
|
||||
} from './ParametricPartTableFilters';
|
||||
|
||||
// Render an individual parameter cell
|
||||
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({
|
||||
categoryId
|
||||
}: Readonly<{
|
||||
@ -231,23 +119,63 @@ export default function ParametricPartTable({
|
||||
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>({});
|
||||
|
||||
/* 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(
|
||||
(templateId: number) => {
|
||||
(templateId: number, operator?: string) => {
|
||||
const filterName = `parameter_${templateId}`;
|
||||
|
||||
setParameterFilters((prev: any) => {
|
||||
const newFilters = { ...prev };
|
||||
Object.keys(newFilters).forEach((key: string) => {
|
||||
if (!operator) {
|
||||
// If no operator is specified, remove all filters for this template
|
||||
setParameterFilters((prev: any) => {
|
||||
const newFilters = { ...prev };
|
||||
// Remove any filters that match the template ID
|
||||
if (key.startsWith(filterName)) {
|
||||
delete newFilters[key];
|
||||
}
|
||||
Object.keys(newFilters).forEach((key: string) => {
|
||||
if (key == filterName) {
|
||||
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();
|
||||
@ -255,37 +183,51 @@ export default function ParametricPartTable({
|
||||
[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(
|
||||
(templateId: number, value: string, operator: string) => {
|
||||
// First, clear any existing filters for this template
|
||||
clearParameterFilter(templateId);
|
||||
const filterName = `parameter_${templateId}`;
|
||||
|
||||
// Map the operator to a more API-friendly format
|
||||
const operations: Record<string, string> = {
|
||||
'=': '',
|
||||
'<': 'lt',
|
||||
'>': 'gt',
|
||||
'<=': 'lte',
|
||||
'>=': 'gte'
|
||||
};
|
||||
setParameterFilters((prev: any) => {
|
||||
const filters = { ...prev };
|
||||
const paramFilters = filters[filterName] || {};
|
||||
|
||||
const op = operations[operator] ?? '';
|
||||
let filterName = `parameter_${templateId}`;
|
||||
paramFilters[operator] = value;
|
||||
|
||||
if (op) {
|
||||
filterName += `_${op}`;
|
||||
}
|
||||
|
||||
setParameterFilters((prev: any) => ({
|
||||
...prev,
|
||||
[filterName]: value?.trim() ?? ''
|
||||
}));
|
||||
return {
|
||||
...filters,
|
||||
[filterName]: paramFilters
|
||||
};
|
||||
});
|
||||
|
||||
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 [selectedTemplate, setSelectedTemplate] = useState<number>(0);
|
||||
const [selectedParameter, setSelectedParameter] = useState<number>(0);
|
||||
@ -357,9 +299,7 @@ export default function ParametricPartTable({
|
||||
title += ` [${template.units}]`;
|
||||
}
|
||||
|
||||
const filterKey = Object.keys(parameterFilters).find((key: string) =>
|
||||
key.startsWith(`parameter_${template.pk}`)
|
||||
);
|
||||
const filters = parameterFilters[`parameter_${template.pk}`] || {};
|
||||
|
||||
return {
|
||||
accessor: `parameter_${template.pk}`,
|
||||
@ -375,16 +315,18 @@ export default function ParametricPartTable({
|
||||
canEdit={user.hasChangeRole(UserRoles.part)}
|
||||
/>
|
||||
),
|
||||
filtering: !!filterKey,
|
||||
filter: ({ close }: { close: () => void }) => (
|
||||
<ParameterFilter
|
||||
template={template}
|
||||
filterValue={filterKey && parameterFilters[filterKey]}
|
||||
setFilter={addParameterFilter}
|
||||
clearFilter={clearParameterFilter}
|
||||
closeFilter={close}
|
||||
/>
|
||||
)
|
||||
filtering: Object.keys(filters).length > 0,
|
||||
filter: ({ close }: { close: () => void }) => {
|
||||
return (
|
||||
<ParameterFilter
|
||||
template={template}
|
||||
filters={parameterFilters[`parameter_${template.pk}`] || {}}
|
||||
setFilter={addParameterFilter}
|
||||
clearFilter={clearParameterFilter}
|
||||
closeFilter={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [user, categoryParameters.data, parameterFilters]);
|
||||
@ -461,7 +403,7 @@ export default function ParametricPartTable({
|
||||
cascade: true,
|
||||
category_detail: true,
|
||||
parameters: true,
|
||||
...parameterFilters
|
||||
...parametricQueryFilters
|
||||
},
|
||||
onCellClick: ({ event, record, index, column, columnIndex }) => {
|
||||
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
|
||||
await cell.click();
|
||||
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
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
|
@ -199,7 +199,9 @@ test('Parts - Allocations', async ({ browser }) => {
|
||||
// Expand allocations against BO0001
|
||||
await build_order_cell.click();
|
||||
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();
|
||||
|
||||
// Check row options for BO0001
|
||||
|
@ -46,9 +46,12 @@ test('Login - Failures', async ({ page }) => {
|
||||
|
||||
test('Login - Change Password', async ({ page }) => {
|
||||
await doLogin(page, 'noaccess', 'youshallnotpass');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Navigate to the 'change password' page
|
||||
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-change-password').click();
|
||||
|
||||
|