From 1b8217e8b3ebb961c7aadf2b4a7ac49b37cf8263 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Jun 2026 22:19:28 +1000 Subject: [PATCH] [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 * Add CHANGELOG entry --------- Co-authored-by: Matthias Mair --- CHANGELOG.md | 1 + .../images/concepts/ui_table_filter_group.png | Bin 0 -> 29037 bytes docs/docs/concepts/user_interface.md | 18 ++ src/frontend/lib/hooks/UseFilterSet.tsx | 57 ++++- src/frontend/lib/types/Filters.tsx | 16 ++ .../src/tables/FilterSelectDrawer.tsx | 233 ++++++++++++++---- .../src/tables/build/BuildOrderFilters.tsx | 4 +- src/frontend/tests/pui_tables.spec.ts | 26 ++ 8 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 docs/docs/assets/images/concepts/ui_table_filter_group.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e18a582a7..fb4eaac156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/docs/assets/images/concepts/ui_table_filter_group.png b/docs/docs/assets/images/concepts/ui_table_filter_group.png new file mode 100644 index 0000000000000000000000000000000000000000..0060f2bef7e523046f6ad47d01e462ac01cccd4c GIT binary patch literal 29037 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 bedgG6iFp!ZPm#cr81!{aZdKj9|LorY-7;hG literal 0 HcmV?d00001 diff --git a/docs/docs/concepts/user_interface.md b/docs/docs/concepts/user_interface.md index 32373e9e6c..80881e7766 100644 --- a/docs/docs/concepts/user_interface.md +++ b/docs/docs/concepts/user_interface.md @@ -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. diff --git a/src/frontend/lib/hooks/UseFilterSet.tsx b/src/frontend/lib/hooks/UseFilterSet.tsx index 52749f0c54..01b2017862 100644 --- a/src/frontend/lib/hooks/UseFilterSet.tsx +++ b/src/frontend/lib/hooks/UseFilterSet.tsx @@ -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 }; } diff --git a/src/frontend/lib/types/Filters.tsx b/src/frontend/lib/types/Filters.tsx index afc07c79d9..1fe7806388 100644 --- a/src/frontend/lib/types/Filters.tsx +++ b/src/frontend/lib/types/Filters.tsx @@ -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[]; +}; + /* * 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; }; diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx index 81b02e3af6..415b2f4074 100644 --- a/src/frontend/src/tables/FilterSelectDrawer.tsx +++ b/src/frontend/src/tables/FilterSelectDrawer.tsx @@ -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 ( + + + {t`Saved Filter Groups`} + + + {filterSet.savedFilterSets.map((set) => ( + + + + + + + + {set.name} + + + + + filterSet.loadFilterSet(set.name)} + > + + + + + filterSet.deleteFilterSet(set.name)} + > + + + + + + + ))} + + + ); +} + export function FilterSelectDrawer({ title, availableFilters, @@ -416,6 +492,8 @@ export function FilterSelectDrawer({ onClose: () => void; }>) { const [addFilter, setAddFilter] = useState(false); + const [saving, setSaving] = useState(false); + const [saveName, setSaveName] = useState(''); // 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 ( {title ?? t`Table Filters`}} + styles={{ body: { height: '100%', overflow: 'hidden' } }} > - - {hasFilters && - filterSet.activeFilters?.map((f) => ( - - ))} - {addFilter && ( - - - - )} - {addFilter && ( - - )} - {!addFilter && - filterSet.activeFilters.length < availableFilters.length && ( + + + {hasFilters && + filterSet.activeFilters?.map((f) => ( + + ))} + {addFilter && ( + + + + )} + {addFilter && ( )} - {!addFilter && filterSet.activeFilters.length > 0 && ( - - )} + {!addFilter && + filterSet.activeFilters.length < availableFilters.length && ( + + )} + {!addFilter && hasFilters && ( + + )} + {!addFilter && + hasFilters && + (saving ? ( + + setSaveName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') confirmSave(); + if (e.key === 'Escape') setSaving(false); + }} + autoFocus + /> + + + + + + + setSaving(false)} + > + + + + + ) : ( + + ))} + + + + + ); diff --git a/src/frontend/src/tables/build/BuildOrderFilters.tsx b/src/frontend/src/tables/build/BuildOrderFilters.tsx index c7f1fd8ee1..69edccf4ac 100644 --- a/src/frontend/src/tables/build/BuildOrderFilters.tsx +++ b/src/frontend/src/tables/build/BuildOrderFilters.tsx @@ -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(), diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index 9fbf0c8051..9e80ce5bb2 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -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 }) => {