2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-25 16:17:58 +00:00

[UI] Default table filters (#11405)

* Support "default filters" for table views

- User overrides apply in preference
- Only when there is no stored value (null)

* Correctly handle partially constructed filters

- Reverse lookup on available filter set

* Add default filters for order tables

* Default filters for company tables

* More default filters

* Add some more default filters

* Bump CHANGELOG

* build fix

* Tweaks for playwright testing

* Tweak playwright test

* Improve test flexibility
This commit is contained in:
Oliver
2026-02-23 19:46:17 +11:00
committed by GitHub
parent d48643319c
commit cef5d4d9c0
33 changed files with 331 additions and 53 deletions

View File

@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
[#11405](https://github.com/inventree/InvenTree/pull/11405) adds default table filters, which hide inactive items by default. The default table filters are overridden by user filter selection, and only apply to the table view initially presented to the user. This means that users can still view inactive items if they choose to, but they will not be shown by default.
[#11222](https://github.com/inventree/InvenTree/pull/11222) adds support for data import using natural keys, allowing for easier association of related objects without needing to know their internal database IDs.
[#11383](https://github.com/inventree/InvenTree/pull/11383) adds "exists_for_model_id", "exists_for_related_model", and "exists_for_related_model_id" filters to the ParameterTemplate API endpoint. These filters allow users to check for the existence of parameters associated with specific models or related models, improving the flexibility and usability of the API.

View File

@@ -39,7 +39,7 @@ export type TableFilterType = 'boolean' | 'choice' | 'date' | 'text' | 'api';
*/
export type TableFilter = {
name: string;
label: string;
label?: string;
description?: string;
type?: TableFilterType;
choices?: TableFilterChoice[];

View File

@@ -1,21 +1,43 @@
import type { FilterSetState, TableFilter } from '@lib/types/Filters';
import { useLocalStorage } from '@mantine/hooks';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
export function useFilterSet(filterKey: string): FilterSetState {
export function useFilterSet(
filterKey: string,
initialFilters?: TableFilter[]
): FilterSetState {
// Array of active filters (saved to local storage)
const [activeFilters, setActiveFilters] = useLocalStorage<TableFilter[]>({
const [storedFilters, setStoredFilters] = useLocalStorage<
TableFilter[] | null
>({
key: `inventree-filterset-${filterKey}`,
defaultValue: [],
defaultValue: null,
sync: false,
getInitialValueInEffect: false
});
const activeFilters: TableFilter[] = useMemo(() => {
if (storedFilters == null) {
// If there are no stored filters, set initial values
const filters = initialFilters || [];
setStoredFilters(filters);
return filters;
}
return storedFilters || [];
}, [storedFilters]);
// Callback to clear all active filters from the table
const clearActiveFilters = useCallback(() => {
setActiveFilters([]);
setStoredFilters([]);
}, []);
const setActiveFilters = useCallback(
(filters: TableFilter[]) => {
setStoredFilters(filters);
},
[setStoredFilters]
);
return {
filterKey,
activeFilters,

View File

@@ -2,17 +2,28 @@ import { randomId } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { FilterSetState } from '@lib/types/Filters';
import type { FilterSetState, TableFilter } from '@lib/types/Filters';
import type { TableState } from '@lib/types/Tables';
import { useFilterSet } from './UseFilterSet';
export type TableStateExtraProps = {
idAccessor?: string;
initialFilters?: TableFilter[];
};
/**
* A custom hook for managing the state of an <InvenTreeTable> component.
*
* Refer to the TableState type definition for more information.
*/
export function useTable(tableName: string, idAccessor = 'pk'): TableState {
export function useTable(
tableName: string,
tableProps: TableStateExtraProps = {
idAccessor: 'pk',
initialFilters: []
}
): TableState {
// Function to generate a new ID (to refresh the table)
function generateTableName() {
return `${tableName.replaceAll('-', '')}-${randomId()}`;
@@ -38,7 +49,10 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
[generateTableName]
);
const filterSet: FilterSetState = useFilterSet(`table-${tableName}`);
const filterSet: FilterSetState = useFilterSet(
`table-${tableName}`,
tableProps.initialFilters
);
// Array of expanded records
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
@@ -59,7 +73,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
// Array of selected primary key values
const selectedIds = useMemo(
() => selectedRecords.map((r) => r[idAccessor || 'pk']),
() => selectedRecords.map((r) => r[tableProps.idAccessor || 'pk']),
[selectedRecords]
);
@@ -89,7 +103,7 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
// Find the matching record in the table
const index = _records.findIndex(
(r) => r[idAccessor || 'pk'] === record.pk
(r) => r[tableProps.idAccessor || 'pk'] === record.pk
);
if (index >= 0) {
@@ -106,6 +120,11 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
[records]
);
const idAccessor = useMemo(
() => tableProps.idAccessor || 'pk',
[tableProps.idAccessor]
);
const [isLoading, setIsLoading] = useState<boolean>(false);
return {

View File

@@ -20,7 +20,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
export function CurrencyTable({
setInfo
}: Readonly<{ setInfo: (info: any) => void }>) {
const table = useTable('currency', 'currency');
const table = useTable('currency', { idAccessor: 'currency' });
const columns = useMemo(() => {
return [
{

View File

@@ -11,7 +11,7 @@ import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable';
function AllUnitTable() {
const table = useTable('all-units', 'name');
const table = useTable('all-units', { idAccessor: 'name' });
const columns = useMemo(() => {
return [
{

View File

@@ -469,6 +469,7 @@ export default function BuildDetail() {
tableName='build-consumed'
showLocation={false}
allowReturn
defaultInStock={null}
params={{
consumed_by: id
}}

View File

@@ -248,6 +248,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
tableName='assigned-stock'
showLocation={false}
allowReturn
defaultInStock={null}
params={{ customer: company.pk }}
/>
) : (

View File

@@ -108,6 +108,7 @@ export default function PurchasingIndex() {
icon: <IconTable />,
content: (
<CompanyTable
companyType='supplier'
path='purchasing/supplier'
params={{ is_supplier: true }}
/>
@@ -157,6 +158,7 @@ export default function PurchasingIndex() {
icon: <IconTable />,
content: (
<CompanyTable
companyType='manufacturer'
path='purchasing/manufacturer'
params={{ is_manufacturer: true }}
/>

View File

@@ -141,6 +141,7 @@ export default function SalesIndex() {
icon: <IconTable />,
content: (
<CompanyTable
companyType='customer'
path='sales/customer'
params={{ is_customer: true }}
/>

View File

@@ -3,6 +3,7 @@ import { t } from '@lingui/core/macro';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { isTrue } from '@lib/functions/Conversion';
import type { TableFilter, TableFilterChoice } from '@lib/types/Filters';
import type {
StatusCodeInterface,
@@ -14,6 +15,47 @@ import {
} from '../states/GlobalStatusState';
import { useGlobalSettingsState } from '../states/SettingsStates';
// Determine the appropriate display label for a given filter, based on its name and the list of available filters
export function filterDisplayLabel(
name: string,
filters?: TableFilter[]
): string {
const filter = filters?.find((f) => f.name === name);
return filter?.label ?? name;
}
// Determine the appropriate display value for a filter, based on its type and value
// This is useful for recreating a display value if we only have a name:value pair
export function filterDisplayValue(
name: string,
value: any,
filters?: TableFilter[]
) {
const filterDef = filters?.find((f) => f.name === name);
if (!filterDef) {
return value;
}
if (!filterDef.type || filterDef.type == 'boolean') {
return isTrue(value) ? t`Yes` : t`No`;
}
if (filterDef.type === 'choice' && filterDef.choices) {
const choice = filterDef.choices.find((c) => c.value === value);
return choice ? choice.label : value;
}
if (filterDef.type === 'choice' && filterDef.choiceFunction) {
const choices = filterDef.choiceFunction();
const choice = choices.find((c) => c.value === value);
return choice ? choice.label : value;
}
// No obvious match - return the raw value
return value;
}
/**
* Return list of available filter options for a given filter
* @param filter - TableFilter object

View File

@@ -28,17 +28,46 @@ import type {
import { IconCheck } from '@tabler/icons-react';
import { StandaloneField } from '../components/forms/StandaloneField';
import { StylishText } from '../components/items/StylishText';
import { getTableFilterOptions } from './Filter';
import {
filterDisplayLabel,
filterDisplayValue,
getTableFilterOptions
} from './Filter';
/*
* Render a preview of a single filter
*/
export function FilterPreview({
filter,
filters
}: Readonly<{
filter: TableFilter;
filters?: TableFilter[];
}>) {
return (
<Group key={filter.name} justify='space-between' gap='xl' wrap='nowrap'>
<Text size='sm'>
{filter.label ?? filterDisplayLabel(filter.name, filters)}
</Text>
<Text size='xs'>
{filter.displayValue ??
filterDisplayValue(filter.name, filter.value, filters)}
</Text>
</Group>
);
}
/*
* Render a single table filter item
*/
function FilterItem({
flt,
filterSet
filterSet,
availableFilters
}: Readonly<{
flt: TableFilter;
filterSet: FilterSetState;
availableFilters?: TableFilter[];
}>) {
const removeFilter = useCallback(() => {
const newFilters = filterSet.activeFilters.filter(
@@ -47,17 +76,29 @@ function FilterItem({
filterSet.setActiveFilters(newFilters);
}, [flt]);
// Find the matching filter definition
const filterProps: TableFilter | undefined = useMemo(() => {
return availableFilters?.find((f) => f.name === flt.name);
}, [availableFilters, flt]);
return (
<Paper p='sm' shadow='sm' radius='xs'>
<Group justify='space-between' key={flt.name} wrap='nowrap'>
<Stack gap='xs'>
<Text size='sm'>{flt.label}</Text>
<Text size='xs'>{flt.description}</Text>
<Text size='sm'>{flt.label ?? filterProps?.label ?? flt.name}</Text>
<Text size='xs'>{flt.description ?? filterProps?.description}</Text>
</Stack>
<Group justify='right'>
<Badge>{flt.displayValue ?? flt.value}</Badge>
<Tooltip label={t`Remove filter`} withinPortal={true}>
<CloseButton size='md' color='red' onClick={removeFilter} />
<Badge>
{flt.displayValue ??
filterDisplayValue(flt.name, flt.value, availableFilters)}
</Badge>
<Tooltip
label={t`Remove filter`}
withinPortal={true}
position='top-end'
>
<CloseButton size='md' c='red' onClick={removeFilter} />
</Tooltip>
</Group>
</Group>
@@ -174,10 +215,10 @@ function FilterAddGroup({
return (
availableFilters
?.filter((flt) => !activeFilterNames.includes(flt.name))
?.sort((a, b) => a.label.localeCompare(b.label))
?.sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name))
?.map((flt) => ({
value: flt.name,
label: flt.label,
label: flt.label ?? flt.name,
description: flt.description
})) ?? []
);
@@ -317,7 +358,12 @@ export function FilterSelectDrawer({
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem key={f.name} flt={f} filterSet={filterSet} />
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>

View File

@@ -9,7 +9,6 @@ import {
Paper,
Space,
Stack,
Text,
Tooltip
} from '@mantine/core';
import {
@@ -37,7 +36,7 @@ import { StylishText } from '../components/items/StylishText';
import useDataExport from '../hooks/UseDataExport';
import { useDeleteApiFormModal } from '../hooks/UseForm';
import { TableColumnSelect } from './ColumnSelect';
import { FilterSelectDrawer } from './FilterSelectDrawer';
import { FilterPreview, FilterSelectDrawer } from './FilterSelectDrawer';
/**
* Render a composite header for an InvenTree table
@@ -272,15 +271,10 @@ export default function InvenTreeTableHeader({
<StylishText size='md'>{t`Active Filters`}</StylishText>
<Divider />
{tableState.filterSet.activeFilters?.map((filter) => (
<Group
key={filter.name}
justify='space-between'
gap='xl'
wrap='nowrap'
>
<Text size='sm'>{filter.label}</Text>
<Text size='xs'>{filter.displayValue}</Text>
</Group>
<FilterPreview
filter={filter}
filters={tableProps.tableFilters}
/>
))}
</Stack>
</Paper>

View File

@@ -64,7 +64,14 @@ export function BuildOrderTable({
salesOrderId?: number;
}>) {
const globalSettings = useGlobalSettingsState();
const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index');
const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index', {
initialFilters: [
{
name: 'outstanding',
value: 'true'
}
]
});
const tableColumns = useMemo(() => {
return [

View File

@@ -29,13 +29,22 @@ import { InvenTreeTable } from '../InvenTreeTable';
* based on the provided filter parameters
*/
export function CompanyTable({
companyType,
params,
path
}: Readonly<{
companyType?: string;
params?: any;
path?: string;
}>) {
const table = useTable('company');
const table = useTable(`company-${companyType ?? 'index'}`, {
initialFilters: [
{
name: 'active',
value: 'true'
}
]
});
const navigate = useNavigate();
const user = useUserState();

View File

@@ -26,7 +26,7 @@ export default function BarcodeScanTable({
const navigate = useNavigate();
const user = useUserState();
const table = useTable('barcode-scan-results', 'id');
const table = useTable('barcode-scan-results', { idAccessor: 'id' });
const tableColumns: TableColumn[] = useMemo(() => {
return [

View File

@@ -338,17 +338,26 @@ export function PartListTable({
enableImport = true,
basePartInstance,
props,
tableName = 'part-list',
defaultPartData
}: Readonly<{
enableImport?: boolean;
props?: InvenTreeTableProps;
basePartInstance?: any;
tableName?: string;
defaultPartData?: any;
}>) {
const tableColumns = useMemo(() => partTableColumns(), []);
const tableFilters = useMemo(() => partTableFilters(), []);
const table = useTable('part-list');
const table = useTable(tableName ?? 'part-list', {
initialFilters: [
{
name: 'active',
value: 'true'
}
]
});
const user = useUserState();
const globalSettings = useGlobalSettingsState();

View File

@@ -43,6 +43,7 @@ export function PartVariantTable({ part }: Readonly<{ part: any }>) {
ancestor: part.pk
}
}}
tableName='part-variants'
basePartInstance={part}
defaultPartData={{
...part,

View File

@@ -19,7 +19,9 @@ export interface PluginRegistryErrorI {
* Table displaying list of plugin registry errors
*/
export default function PluginErrorTable() {
const table = useTable('registryErrors', 'id');
const table = useTable('registryErrors', {
idAccessor: 'id'
});
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
useMemo(

View File

@@ -54,7 +54,29 @@ export function ManufacturerPartTable({
return tId;
}, [manufacturerId, partId]);
const table = useTable(tableId);
const initialFilters = useMemo(() => {
const filters: TableFilter[] = [];
if (!manufacturerId) {
filters.push({
name: 'manufacturer_active',
value: 'true'
});
}
if (!partId) {
filters.push({
name: 'part_active',
value: 'true'
});
}
return filters;
}, [manufacturerId, partId]);
const table = useTable(tableId, {
initialFilters: initialFilters
});
const user = useUserState();

View File

@@ -60,7 +60,14 @@ export function PurchaseOrderTable({
supplierPartId?: number;
externalBuildId?: number;
}>) {
const table = useTable('purchase-order');
const table = useTable('purchase-order', {
initialFilters: [
{
name: 'outstanding',
value: 'true'
}
]
});
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {

View File

@@ -54,7 +54,34 @@ export function SupplierPartTable({
partId?: number;
supplierId?: number;
}>): ReactNode {
const table = useTable('supplierparts');
const initialFilters = useMemo(() => {
const filters: TableFilter[] = [
{
name: 'active',
value: 'true'
}
];
if (!supplierId) {
filters.push({
name: 'supplier_active',
value: 'true'
});
}
if (!partId) {
filters.push({
name: 'part_active',
value: 'true'
});
}
return filters;
}, [supplierId, partId]);
const table = useTable('supplierparts', {
initialFilters: initialFilters
});
const user = useUserState();

View File

@@ -56,7 +56,18 @@ export function ReturnOrderTable({
partId?: number;
customerId?: number;
}>) {
const table = useTable(!!partId ? 'returnorders-part' : 'returnorders-index');
const table = useTable(
!!partId ? 'returnorders-part' : 'returnorders-index',
{
initialFilters: [
{
name: 'outstanding',
value: 'true'
}
]
}
);
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {

View File

@@ -58,7 +58,14 @@ export function SalesOrderTable({
partId?: number;
customerId?: number;
}>) {
const table = useTable(!!partId ? 'salesorder-part' : 'salesorder-index');
const table = useTable(!!partId ? 'salesorder-part' : 'salesorder-index', {
initialFilters: [
{
name: 'outstanding',
value: 'true'
}
]
});
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {

View File

@@ -48,7 +48,7 @@ export function ApiTokenTable({
return [];
}, [only_myself]);
const table = useTable('api-tokens', 'id');
const table = useTable('api-tokens', { idAccessor: 'id' });
const tableColumns = useMemo(() => {
const cols = [

View File

@@ -61,7 +61,7 @@ export function EmailTable() {
];
}, []);
const table = useTable('emails', 'pk');
const table = useTable('emails', { idAccessor: 'pk' });
const [selectedEmailId, setSelectedEmailId] = useState<string>('');

View File

@@ -315,6 +315,8 @@ export function StockItemTable({
showLocation = true,
showPricing = true,
allowReturn = false,
initialFilters,
defaultInStock = true,
tableName = 'stockitems'
}: Readonly<{
params?: any;
@@ -322,9 +324,34 @@ export function StockItemTable({
showLocation?: boolean;
showPricing?: boolean;
allowReturn?: boolean;
defaultInStock?: boolean | null;
initialFilters?: TableFilter[];
tableName: string;
}>) {
const table = useTable(tableName);
const initialStockFilters: TableFilter[] = useMemo(() => {
if (!!initialFilters) {
return initialFilters;
}
const filters: TableFilter[] = [];
// Optionally set the default "in_stock" filter
// Typically, we default to only displaying "in_stock" items,
// but this can be overridden by the caller if required
if (defaultInStock != undefined && defaultInStock != null) {
filters.push({
name: 'in_stock',
value: defaultInStock ? 'true' : 'false'
});
}
return filters;
}, [defaultInStock, initialFilters]);
const table = useTable(tableName, {
initialFilters: initialStockFilters
});
const user = useUserState();
const settings = useGlobalSettingsState();

View File

@@ -723,6 +723,8 @@ test('Build Order - External', async ({ browser }) => {
await navigate(page, 'manufacturing/build-order/26/details');
await loadTab(page, 'External Orders');
await clearTableFilters(page);
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
});

View File

@@ -15,21 +15,28 @@ test('Company', async ({ browser }) => {
await navigate(page, 'company/1/details');
await page.getByLabel('Details').getByText('DigiKey Electronics').waitFor();
await page.getByRole('cell', { name: 'https://www.digikey.com/' }).waitFor();
await loadTab(page, 'Supplied Parts');
await page
.getByRole('cell', { name: 'RR05P100KDTR-ND', exact: true })
.waitFor();
await loadTab(page, 'Purchase Orders');
await clearTableFilters(page);
await page.getByRole('cell', { name: 'Molex connectors' }).first().waitFor();
await loadTab(page, 'Stock Items');
await page
.getByRole('cell', { name: 'Blue plastic enclosure' })
.first()
.waitFor();
await loadTab(page, 'Contacts');
await page.getByRole('cell', { name: 'jimmy.mcleod@digikey.com' }).waitFor();
await loadTab(page, 'Addresses');
await page.getByRole('cell', { name: 'Carla Tunnel' }).waitFor();
await loadTab(page, 'Attachments');
await loadTab(page, 'Notes');

View File

@@ -66,14 +66,16 @@ test('Parts - Tabs', async ({ browser }) => {
});
test('Parts - Manufacturer Parts', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/84/suppliers' });
const page = await doCachedLogin(browser, { url: 'part/84/' });
// Load the "suppliers" tab
await loadTab(page, 'Suppliers');
await page.getByText('Hammond Manufacturing').click();
await loadTab(page, 'Parameters');
await loadTab(page, 'Suppliers');
await loadTab(page, 'Attachments');
// Wait for manufacturer part page to load
await page.getByText('1551ACLR - 1551ACLR').waitFor();
await loadTab(page, 'Parameters');
await loadTab(page, 'Attachments');
});
test('Parts - Supplier Parts', async ({ browser }) => {

View File

@@ -25,6 +25,14 @@ test('Purchasing - Index', async ({ browser }) => {
await showCalendarView(page);
await showTableView(page);
// Check default filters are applied
// By default, only outstanding orders are visible
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
// Clearing the filters, more orders should be visible
await clearTableFilters(page);
await page.getByText(/1 - 1\d \/ 1\d/).waitFor();
// Suppliers tab
await loadTab(page, 'Suppliers');
await showParametricView(page);

View File

@@ -33,7 +33,7 @@ test('Exporting - Orders', async ({ browser }) => {
await page.getByText('Process completed successfully').waitFor();
// Download list of purchase order items
await page.getByRole('cell', { name: 'PO0011' }).click();
await page.getByRole('cell', { name: 'PO0014' }).click();
await loadTab(page, 'Line Items');
await openExportDialog(page);
await page.getByRole('button', { name: 'Export', exact: true }).click();

View File

@@ -62,7 +62,7 @@ test('Printing - Report Printing', async ({ browser }) => {
await loadTab(page, 'Purchase Orders');
await activateTableView(page);
await page.getByRole('cell', { name: 'PO0009' }).click();
await page.getByRole('cell', { name: 'PO0013' }).click();
// Select "print report"
await page.getByLabel('action-menu-printing-actions').click();