mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-12 03:28:37 +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:
@@ -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