2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-02 19:50:59 +00:00

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
This commit is contained in:
Oliver
2025-06-08 22:03:50 +10:00
committed by GitHub
parent 026904b361
commit 6b261e122d
13 changed files with 392 additions and 181 deletions

View File

@ -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);

View 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]);
}

View File

@ -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');

View File

@ -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

View File

@ -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();