feat(frontend): Add filter navigation remove button (#12668)

* feat(frontend): Add filter navigation remove button

* extend docs

* extract and extend labels

* add spacer

* fix test

* small fix

* remove unneeded labels

* add mechanism for not triggering on viewsets

* reduce diff for now

* fix test

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2026-08-21 09:34:11 +10:00
committed by GitHub
co-authored by Oliver
parent be9faff766
commit 71787d4546
9 changed files with 55 additions and 16 deletions
+6 -3
View File
@@ -98,22 +98,25 @@ class TagsFilter(rest_filters.CharFilter):
"""Filter which accepts a comma-separated list of tag names and returns only objects that have ALL of the specified tags.
Example usage in a FilterSet:
tags = TagsFilter(label=_('Tags'))
tags = TagsFilter()
Example query:
?tags=apple,banana → returns only items tagged with both 'apple' AND 'banana'
"""
def __init__(self, *args, **kwargs):
_is_viewset: bool = False
def __init__(self, is_viewset: bool = False, *args, **kwargs):
"""Initialize the filter."""
if 'label' not in kwargs:
kwargs['label'] = _('Tags')
self._is_viewset = is_viewset
super().__init__(*args, **kwargs)
def filter(self, qs, value):
"""Filter queryset to items matching all provided tag names."""
if not value:
if not value or (self._is_viewset and InvenTree.helpers.is_bool(value)):
return qs
tag_names = [t.strip() for t in value.split(',') if t.strip()]
+2 -2
View File
@@ -152,7 +152,7 @@ class ManufacturerPartFilter(FilterSet):
field_name='manufacturer__active', label=_('Manufacturer is Active')
)
tags = common.filters.TagsFilter(label=_('Tags'))
tags = common.filters.TagsFilter()
class ManufacturerOutputOptions(OutputConfiguration):
@@ -308,7 +308,7 @@ class SupplierPartFilter(FilterSet):
else:
return queryset.exclude(in_stock__gt=0)
tags = common.filters.TagsFilter(label=_('Tags'))
tags = common.filters.TagsFilter()
class SupplierPartOutputOptions(OutputConfiguration):
+2
View File
@@ -374,6 +374,8 @@ class PurchaseOrderFilter(OrderFilter):
"""
return queryset.filter(lines__build_order=build).distinct()
tags = common.filters.TagsFilter(is_viewset=True)
class PurchaseOrderOutputOptions(OutputConfiguration):
"""Output options for the PurchaseOrder endpoint."""
+3
View File
@@ -171,6 +171,9 @@ class PurchaseOrderTest(OrderTest):
self.filter({'supplier_part': 3}, 2)
self.filter({'supplier_part': 4}, 0)
# Filter by "tags"
self.filter({'tags': True}, 7)
def test_total_price(self):
"""Unit tests for the 'total_price' field."""
# Ensure we have exchange rate data
+2 -2
View File
@@ -412,7 +412,7 @@ class StockLocationFilter(FilterSet):
return queryset
tags = common.filters.TagsFilter(label=_('Tags'))
tags = common.filters.TagsFilter()
class StockLocationMixin(SerializerContextMixin):
@@ -1067,7 +1067,7 @@ class StockFilter(FilterSet):
children = loc_obj.getUniqueChildren()
return queryset.filter(location__in=children)
tags = common.filters.TagsFilter(label=_('Tags'))
tags = common.filters.TagsFilter()
class StockApiMixin(SerializerContextMixin):
@@ -1,7 +1,13 @@
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
import { ActionIcon, Group, Space, Text, Tooltip } from '@mantine/core';
import {
IconCancel,
IconChevronLeft,
IconChevronRight
} from '@tabler/icons-react';
import { t } from '@lingui/core/macro';
import { useNavigate } from 'react-router-dom';
import { removeDetailNavigationParams } from '../../functions/DetailNavigation';
import type { DetailNavigationState } from '../../hooks/UseDetailNavigation';
export function DetailNavigation({
@@ -13,11 +19,22 @@ export function DetailNavigation({
navigation.position ||
navigation.isLoading
);
const navigate = useNavigate();
function handleClear() {
const url = new URL(window.location.href);
removeDetailNavigationParams(url.searchParams);
navigate(url);
}
if (!hasNavigation) {
return null;
}
const lbl_next = t`Next item`;
const lbl_prev = t`Previous item`;
const lbl_clear = t`Remove navigation filters from current view`;
return (
<Group
gap={5}
@@ -37,7 +54,18 @@ export function DetailNavigation({
{t`${navigation.position.current} of ${navigation.position.total}`}
</Text>
)}
<Tooltip label={t`Previous`} position='top'>
<Tooltip label={lbl_clear} position='top'>
<ActionIcon
onClick={handleClear}
size='md'
variant='subtle'
aria-label={lbl_clear}
>
<IconCancel size='1.25rem' />
</ActionIcon>
</Tooltip>
<Space />
<Tooltip label={lbl_prev} position='top'>
<ActionIcon
component='a'
href={navigation.previous?.href}
@@ -45,12 +73,12 @@ export function DetailNavigation({
disabled={!navigation.previous}
size='md'
variant='subtle'
aria-label={t`Previous`}
aria-label={lbl_prev}
>
<IconChevronLeft size='1.25rem' />
</ActionIcon>
</Tooltip>
<Tooltip label={t`Next`} position='top'>
<Tooltip label={lbl_next} position='top'>
<ActionIcon
component='a'
href={navigation.next?.href}
@@ -58,7 +86,7 @@ export function DetailNavigation({
disabled={!navigation.next}
size='md'
variant='subtle'
aria-label={t`Next`}
aria-label={lbl_next}
>
<IconChevronRight size='1.25rem' />
</ActionIcon>
@@ -88,7 +88,7 @@ function decodeDetailNavigationApi(apiUrl: string): string {
return DETAIL_NAVIGATION_API_URLS.get(apiUrl) ?? apiUrl;
}
function removeDetailNavigationParams(params: URLSearchParams) {
export function removeDetailNavigationParams(params: URLSearchParams) {
DETAIL_NAVIGATION_PARAM_KEYS.forEach((key) => {
params.delete(key);
});
+2 -2
View File
@@ -243,13 +243,13 @@ test('Tables - Detail navigation', async ({ browser }) => {
const detailNavigation = breadcrumbBar.getByTestId('detail-navigation');
await expect(detailNavigation).toBeVisible();
const previous = page.getByLabel('Previous', { exact: true });
const previous = page.getByLabel('Previous item', { exact: true });
await expect(previous).toBeVisible();
await expect(previous.locator('svg')).toBeVisible();
await expect(previous).toHaveAttribute('data-disabled', 'true');
await expect(previous).not.toHaveAttribute('href');
const next = page.getByLabel('Next', { exact: true });
const next = page.getByLabel('Next item', { exact: true });
await expect(next).toBeVisible();
await expect(next.locator('svg')).toBeVisible();
await expect(next).not.toHaveAttribute('data-disabled');