2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-04 03:11:46 +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

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