2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-11 19:27:02 +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
+54 -3
View File
@@ -1,6 +1,10 @@
import { useLocalStorage } from '@mantine/hooks';
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(
filterKey: string,
@@ -16,6 +20,16 @@ export default function useFilterSet(
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(() => {
if (storedFilters == null) {
setStoredFilters(initialFilters || []);
@@ -26,7 +40,6 @@ export default function useFilterSet(
return storedFilters ?? initialFilters ?? [];
}, [storedFilters, initialFilters]);
// Callback to clear all active filters from the table
const clearActiveFilters = useCallback(() => {
setStoredFilters([]);
}, []);
@@ -38,10 +51,48 @@ export default function useFilterSet(
[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 {
filterKey,
activeFilters,
setActiveFilters,
clearActiveFilters
clearActiveFilters,
savedFilterSets: storedNamedSets ?? [],
saveFilterSet,
loadFilterSet,
deleteFilterSet
};
}
+16
View File
@@ -57,6 +57,14 @@ export type TableFilter = {
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.
* 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
* setActiveFilters: A function to set the 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 = {
filterKey: string;
activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => 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,
TableFilterType
} from '@lib/types/Filters';
import { IconCheck } from '@tabler/icons-react';
import {
IconCheck,
IconFilterStar,
IconReload,
IconX
} from '@tabler/icons-react';
import { api } from '../App';
import { StandaloneField } from '../components/forms/StandaloneField';
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({
title,
availableFilters,
@@ -416,6 +492,8 @@ export function FilterSelectDrawer({
onClose: () => void;
}>) {
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
useEffect(() => {
@@ -423,11 +501,18 @@ export function FilterSelectDrawer({
}, [filterSet.activeFilters]);
const hasFilters: boolean = useMemo(() => {
const filters = filterSet?.activeFilters ?? [];
return filters.length > 0;
return (filterSet?.activeFilters ?? []).length > 0;
}, [filterSet.activeFilters]);
const confirmSave = useCallback(() => {
const name = saveName.trim();
if (name) {
filterSet.saveFilterSet(name);
}
setSaveName('');
setSaving(false);
}, [saveName, filterSet]);
return (
<Drawer
size='sm'
@@ -439,55 +524,111 @@ export function FilterSelectDrawer({
'aria-label': 'filter-drawer-close'
}}
title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>}
styles={{ body: { height: '100%', overflow: 'hidden' } }}
>
<Divider />
<Space h='sm' />
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>
<FilterAddGroup
filterSet={filterSet}
availableFilters={availableFilters}
/>
</Stack>
)}
{addFilter && (
<Button
onClick={() => setAddFilter(false)}
color='orange'
variant='subtle'
>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Stack gap='xs' justify='space-between'>
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>
<FilterAddGroup
filterSet={filterSet}
availableFilters={availableFilters}
/>
</Stack>
)}
{addFilter && (
<Button
onClick={() => setAddFilter(true)}
color='green'
variant='subtle'
onClick={() => setAddFilter(false)}
color='orange'
variant='light'
>
<Text>{t`Add Filter`}</Text>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter && filterSet.activeFilters.length > 0 && (
<Button
onClick={filterSet.clearActiveFilters}
color='red'
variant='subtle'
>
<Text>{t`Clear Filters`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Button
onClick={() => setAddFilter(true)}
color='green'
variant='light'
>
<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>
</Drawer>
);
@@ -44,8 +44,6 @@ export default function BuildOrderFilters({
OrderStatusFilter({ model: ModelType.build }),
OverdueFilter(),
AssignedToMeFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter(),
HasProjectCodeFilter(),
IssuedByFilter(),
@@ -57,6 +55,8 @@ export default function BuildOrderFilters({
const dateFilters: TableFilter[] = [
MinDateFilter(),
MaxDateFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
+26
View File
@@ -3,6 +3,7 @@ import { stevenuser } from './defaults.js';
import {
clearTableFilters,
navigate,
openFilterDrawer,
setTableChoiceFilter,
toggleColumnSorting
} from './helpers.js';
@@ -39,6 +40,31 @@ test('Tables - Filters', async ({ browser }) => {
await setTableChoiceFilter(page, 'Has Start Date', 'Yes');
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 }) => {