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 0000000000..0060f2bef7 Binary files /dev/null and b/docs/docs/assets/images/concepts/ui_table_filter_group.png differ 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 }) => {