2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-02 19:50:59 +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:
Oliver
2025-06-06 15:06:11 +10:00
committed by GitHub
parent a63efc4089
commit 9138bad8bc
14 changed files with 533 additions and 14 deletions

View File

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

View File

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

View File

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