2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-06 00:44:25 +00:00

[UI] Table filter set (#12079)

* Save and load custom filter sets

* Tweak UI logic

* Adjust icons

* More refactoring

* Update UI docs

* Playwright tests

* Add docs image

* Fix image name

* Update docs/docs/concepts/user_interface.md

Co-authored-by: Matthias Mair <code@mjmair.com>

* Add CHANGELOG entry

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-06-04 22:19:28 +10:00
committed by GitHub
parent 3dfd03fa89
commit 1b8217e8b3
8 changed files with 304 additions and 51 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface.
- [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags. - [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags.
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation. - [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created. - [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+18
View File
@@ -158,6 +158,24 @@ Select the "table filters" button to open the filter selection menu
Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view. Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view.
#### Saved Filter Groups
Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually.
The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard.
{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }}
Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions:
- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters.
- **Delete** (red × icon): Permanently removes the saved filter group.
Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices.
!!! info "Loading a filter group replaces active filters"
Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten.
### Data Sorting ### Data Sorting
Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header. Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header.
+54 -3
View File
@@ -1,6 +1,10 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import type { FilterSetState, TableFilter } from '../types/Filters'; import type {
FilterSetState,
NamedFilterSet,
TableFilter
} from '../types/Filters';
export default function useFilterSet( export default function useFilterSet(
filterKey: string, filterKey: string,
@@ -16,6 +20,16 @@ export default function useFilterSet(
getInitialValueInEffect: false getInitialValueInEffect: false
}); });
// Named filter set snapshots (saved to local storage, separate key)
const [storedNamedSets, setStoredNamedSets] = useLocalStorage<
NamedFilterSet[]
>({
key: `inventree-filtersets-${filterKey}`,
defaultValue: [],
sync: false,
getInitialValueInEffect: false
});
useEffect(() => { useEffect(() => {
if (storedFilters == null) { if (storedFilters == null) {
setStoredFilters(initialFilters || []); setStoredFilters(initialFilters || []);
@@ -26,7 +40,6 @@ export default function useFilterSet(
return storedFilters ?? initialFilters ?? []; return storedFilters ?? initialFilters ?? [];
}, [storedFilters, initialFilters]); }, [storedFilters, initialFilters]);
// Callback to clear all active filters from the table
const clearActiveFilters = useCallback(() => { const clearActiveFilters = useCallback(() => {
setStoredFilters([]); setStoredFilters([]);
}, []); }, []);
@@ -38,10 +51,48 @@ export default function useFilterSet(
[setStoredFilters] [setStoredFilters]
); );
const saveFilterSet = useCallback(
(name: string) => {
const snapshot = activeFilters.map(
({ name: n, value, displayValue }) => ({
name: n,
value,
displayValue
})
);
setStoredNamedSets((prev) => {
const without = (prev ?? []).filter((s) => s.name !== name);
return [...without, { name, filters: snapshot }];
});
},
[activeFilters, setStoredNamedSets]
);
const loadFilterSet = useCallback(
(name: string) => {
const saved = (storedNamedSets ?? []).find((s) => s.name === name);
if (saved) {
setStoredFilters(saved.filters as TableFilter[]);
}
},
[storedNamedSets, setStoredFilters]
);
const deleteFilterSet = useCallback(
(name: string) => {
setStoredNamedSets((prev) => (prev ?? []).filter((s) => s.name !== name));
},
[setStoredNamedSets]
);
return { return {
filterKey, filterKey,
activeFilters, activeFilters,
setActiveFilters, setActiveFilters,
clearActiveFilters clearActiveFilters,
savedFilterSets: storedNamedSets ?? [],
saveFilterSet,
loadFilterSet,
deleteFilterSet
}; };
} }
+16
View File
@@ -57,6 +57,14 @@ export type TableFilter = {
multi?: boolean; multi?: boolean;
}; };
/*
* A named snapshot of a set of active filters, saved to local storage.
*/
export type NamedFilterSet = {
name: string;
filters: Pick<TableFilter, 'name' | 'value' | 'displayValue'>[];
};
/* /*
* Type definition for representing the state of a group of filters. * Type definition for representing the state of a group of filters.
* These may be applied to a data view (e.g. table, calendar) to filter the displayed data. * These may be applied to a data view (e.g. table, calendar) to filter the displayed data.
@@ -65,10 +73,18 @@ export type TableFilter = {
* activeFilters: An array of active filters * activeFilters: An array of active filters
* setActiveFilters: A function to set the active filters * setActiveFilters: A function to set the active filters
* clearActiveFilters: A function to clear all active filters * clearActiveFilters: A function to clear all active filters
* savedFilterSets: Named filter set snapshots persisted to local storage
* saveFilterSet: Save the current active filters under a given name
* loadFilterSet: Replace active filters with a previously saved named set
* deleteFilterSet: Remove a saved named filter set by name
*/ */
export type FilterSetState = { export type FilterSetState = {
filterKey: string; filterKey: string;
activeFilters: TableFilter[]; activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => void; setActiveFilters: (filters: TableFilter[]) => void;
clearActiveFilters: () => void; clearActiveFilters: () => void;
savedFilterSets: NamedFilterSet[];
saveFilterSet: (name: string) => void;
loadFilterSet: (name: string) => void;
deleteFilterSet: (name: string) => void;
}; };
+187 -46
View File
@@ -28,7 +28,12 @@ import type {
TableFilterChoice, TableFilterChoice,
TableFilterType TableFilterType
} from '@lib/types/Filters'; } from '@lib/types/Filters';
import { IconCheck } from '@tabler/icons-react'; import {
IconCheck,
IconFilterStar,
IconReload,
IconX
} from '@tabler/icons-react';
import { api } from '../App'; import { api } from '../App';
import { StandaloneField } from '../components/forms/StandaloneField'; import { StandaloneField } from '../components/forms/StandaloneField';
import { import {
@@ -402,6 +407,77 @@ function FilterAddGroup({
); );
} }
function SavedFilterSets({
filterSet
}: Readonly<{
filterSet: FilterSetState;
}>) {
if (filterSet.savedFilterSets.length === 0) {
return null;
}
return (
<Stack gap='xs' justify='flex-end'>
<Space h='md' />
<StylishText size='md'>{t`Saved Filter Groups`}</StylishText>
<Divider />
<Stack gap='xs'>
{filterSet.savedFilterSets.map((set) => (
<Paper
key={`filter-group-${set.name}`}
p='sm'
shadow='sm'
radius='xs'
>
<Group justify='space-between' wrap='nowrap'>
<Group gap='xs' wrap='nowrap'>
<ActionIcon size='sm' variant='transparent'>
<IconFilterStar />
</ActionIcon>
<Text size='sm' style={{ flex: 1 }} truncate>
{set.name}
</Text>
</Group>
<Group gap='xs' wrap='nowrap'>
<Tooltip
label={t`Load filter group`}
withinPortal
position='top-end'
>
<ActionIcon
size='sm'
variant='transparent'
color='green'
aria-label={`load-filter-group-${set.name}`}
onClick={() => filterSet.loadFilterSet(set.name)}
>
<IconReload />
</ActionIcon>
</Tooltip>
<Tooltip
label={t`Delete filter group`}
withinPortal
position='top-end'
>
<ActionIcon
size='sm'
variant='transparent'
color='red'
aria-label={`delete-filter-group-${set.name}`}
onClick={() => filterSet.deleteFilterSet(set.name)}
>
<IconX style={{ transform: 'rotate(180deg)' }} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
))}
</Stack>
</Stack>
);
}
export function FilterSelectDrawer({ export function FilterSelectDrawer({
title, title,
availableFilters, availableFilters,
@@ -416,6 +492,8 @@ export function FilterSelectDrawer({
onClose: () => void; onClose: () => void;
}>) { }>) {
const [addFilter, setAddFilter] = useState<boolean>(false); const [addFilter, setAddFilter] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
const [saveName, setSaveName] = useState<string>('');
// Hide the "add filter" selection whenever the selected filters change // Hide the "add filter" selection whenever the selected filters change
useEffect(() => { useEffect(() => {
@@ -423,11 +501,18 @@ export function FilterSelectDrawer({
}, [filterSet.activeFilters]); }, [filterSet.activeFilters]);
const hasFilters: boolean = useMemo(() => { const hasFilters: boolean = useMemo(() => {
const filters = filterSet?.activeFilters ?? []; return (filterSet?.activeFilters ?? []).length > 0;
return filters.length > 0;
}, [filterSet.activeFilters]); }, [filterSet.activeFilters]);
const confirmSave = useCallback(() => {
const name = saveName.trim();
if (name) {
filterSet.saveFilterSet(name);
}
setSaveName('');
setSaving(false);
}, [saveName, filterSet]);
return ( return (
<Drawer <Drawer
size='sm' size='sm'
@@ -439,55 +524,111 @@ export function FilterSelectDrawer({
'aria-label': 'filter-drawer-close' 'aria-label': 'filter-drawer-close'
}} }}
title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>} title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>}
styles={{ body: { height: '100%', overflow: 'hidden' } }}
> >
<Divider /> <Divider />
<Space h='sm' /> <Space h='sm' />
<Stack gap='xs'> <Stack gap='xs' justify='space-between'>
{hasFilters && <Stack gap='xs'>
filterSet.activeFilters?.map((f) => ( {hasFilters &&
<FilterItem filterSet.activeFilters?.map((f) => (
key={f.name} <FilterItem
flt={f} key={f.name}
filterSet={filterSet} flt={f}
availableFilters={availableFilters} filterSet={filterSet}
/> availableFilters={availableFilters}
))} />
{addFilter && ( ))}
<Stack gap='xs'> {addFilter && (
<FilterAddGroup <Stack gap='xs'>
filterSet={filterSet} <FilterAddGroup
availableFilters={availableFilters} filterSet={filterSet}
/> availableFilters={availableFilters}
</Stack> />
)} </Stack>
{addFilter && ( )}
<Button {addFilter && (
onClick={() => setAddFilter(false)}
color='orange'
variant='subtle'
>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Button <Button
onClick={() => setAddFilter(true)} onClick={() => setAddFilter(false)}
color='green' color='orange'
variant='subtle' variant='light'
> >
<Text>{t`Add Filter`}</Text> <Text>{t`Cancel`}</Text>
</Button> </Button>
)} )}
{!addFilter && filterSet.activeFilters.length > 0 && ( {!addFilter &&
<Button filterSet.activeFilters.length < availableFilters.length && (
onClick={filterSet.clearActiveFilters} <Button
color='red' onClick={() => setAddFilter(true)}
variant='subtle' color='green'
> variant='light'
<Text>{t`Clear Filters`}</Text> >
</Button> <Text>{t`Add Filter`}</Text>
)} </Button>
)}
{!addFilter && hasFilters && (
<Button
onClick={filterSet.clearActiveFilters}
color='red'
variant='light'
>
<Text>{t`Clear Filters`}</Text>
</Button>
)}
{!addFilter &&
hasFilters &&
(saving ? (
<Group gap='xs' wrap='nowrap'>
<TextInput
style={{ flex: 1 }}
aria-label='filter-group-name'
placeholder={t`Group name`}
value={saveName}
onChange={(e) => setSaveName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmSave();
if (e.key === 'Escape') setSaving(false);
}}
autoFocus
/>
<Tooltip label={t`Save`} withinPortal>
<ActionIcon
aria-label='save-filter-set'
color='green'
size='sm'
variant='transparent'
onClick={confirmSave}
disabled={!saveName.trim()}
>
<IconCheck />
</ActionIcon>
</Tooltip>
<Tooltip label={t`Cancel`} withinPortal>
<ActionIcon
aria-label='cancel-save-filter-set'
color='red'
size='sm'
variant='transparent'
onClick={() => setSaving(false)}
>
<IconX />
</ActionIcon>
</Tooltip>
</Group>
) : (
<Button
color='blue'
variant='light'
onClick={() => setSaving(true)}
>
<Text>{t`Save current filters`}</Text>
</Button>
))}
</Stack>
<Stack gap='xs'>
<SavedFilterSets filterSet={filterSet} />
<Space h='sm' />
</Stack>
</Stack> </Stack>
</Drawer> </Drawer>
); );
@@ -44,8 +44,6 @@ export default function BuildOrderFilters({
OrderStatusFilter({ model: ModelType.build }), OrderStatusFilter({ model: ModelType.build }),
OverdueFilter(), OverdueFilter(),
AssignedToMeFilter(), AssignedToMeFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter(), ProjectCodeFilter(),
HasProjectCodeFilter(), HasProjectCodeFilter(),
IssuedByFilter(), IssuedByFilter(),
@@ -57,6 +55,8 @@ export default function BuildOrderFilters({
const dateFilters: TableFilter[] = [ const dateFilters: TableFilter[] = [
MinDateFilter(), MinDateFilter(),
MaxDateFilter(), MaxDateFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
CreatedBeforeFilter(), CreatedBeforeFilter(),
CreatedAfterFilter(), CreatedAfterFilter(),
TargetDateBeforeFilter(), TargetDateBeforeFilter(),
+26
View File
@@ -3,6 +3,7 @@ import { stevenuser } from './defaults.js';
import { import {
clearTableFilters, clearTableFilters,
navigate, navigate,
openFilterDrawer,
setTableChoiceFilter, setTableChoiceFilter,
toggleColumnSorting toggleColumnSorting
} from './helpers.js'; } from './helpers.js';
@@ -39,6 +40,31 @@ test('Tables - Filters', async ({ browser }) => {
await setTableChoiceFilter(page, 'Has Start Date', 'Yes'); await setTableChoiceFilter(page, 'Has Start Date', 'Yes');
await clearTableFilters(page); await clearTableFilters(page);
// Next, let's create a "custom filter group" and apply it
await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).click();
await page.getByRole('option', { name: 'Outstanding' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
await page.getByRole('option', { name: 'Yes' }).click();
// Save the filter group
await page.getByRole('button', { name: 'Save current filters' }).click();
await page.getByRole('textbox', { name: 'filter-group-name' }).fill('custom');
await page
.getByRole('button', { name: 'save-filter-set', exact: true })
.click();
// Clear filters, and then restore from saved group
await page.getByRole('button', { name: 'Clear Filters' }).click();
await page.getByRole('button', { name: 'load-filter-group-custom' }).click();
await page.getByText('Show outstanding items').first().waitFor();
// Remove the filter group
await page
.getByRole('button', { name: 'delete-filter-group-custom' })
.click();
}); });
test('Tables - Pagination', async ({ browser }) => { test('Tables - Pagination', async ({ browser }) => {