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
+3
View File
@@ -92,6 +92,9 @@ The navigation context is stored in the detail URL. This makes links shareable a
The navigation controls also work with detail pages provided by frontend plugins that use the standard table and page components.
!!! tip
This behaviour can be controlled by users with the setting `USE_TABLE_NAVIGATION`.
### Navigation Tree
On some pages, a navigation tree is provided on the left-hand side of the page, next to the breadcrumbs. The navigation tree provides a hierarchical view of the current section of the system, allowing users to quickly navigate to related pages and sections.
+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');