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:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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.
|
||||
- [#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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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.
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user