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:
@ -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
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();
|
||||
|
||||
|
Reference in New Issue
Block a user