From 71787d454641da8445c6dfae44b0edb8badd804c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 21 Aug 2026 01:34:11 +0200 Subject: [PATCH] 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 --- docs/docs/concepts/ui/index.md | 3 ++ src/backend/InvenTree/common/filters.py | 9 +++-- src/backend/InvenTree/company/api.py | 4 +- src/backend/InvenTree/order/api.py | 2 + src/backend/InvenTree/order/test_api.py | 3 ++ src/backend/InvenTree/stock/api.py | 4 +- .../src/components/nav/DetailNavigation.tsx | 40 ++++++++++++++++--- .../src/functions/DetailNavigation.tsx | 2 +- src/frontend/tests/pui_tables.spec.ts | 4 +- 9 files changed, 55 insertions(+), 16 deletions(-) diff --git a/docs/docs/concepts/ui/index.md b/docs/docs/concepts/ui/index.md index 758ee4a904..a38b39c26e 100644 --- a/docs/docs/concepts/ui/index.md +++ b/docs/docs/concepts/ui/index.md @@ -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. diff --git a/src/backend/InvenTree/common/filters.py b/src/backend/InvenTree/common/filters.py index e8a2d0741e..aaa491e3b9 100644 --- a/src/backend/InvenTree/common/filters.py +++ b/src/backend/InvenTree/common/filters.py @@ -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()] diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 5c1dc26206..c3e28728ca 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -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): diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 690c1fc9e8..11596b6598 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -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.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 6752a3b575..100b4263be 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -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 diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 994d039c85..114be565ed 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -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): diff --git a/src/frontend/src/components/nav/DetailNavigation.tsx b/src/frontend/src/components/nav/DetailNavigation.tsx index cd4abdb052..a5f072cbd2 100644 --- a/src/frontend/src/components/nav/DetailNavigation.tsx +++ b/src/frontend/src/components/nav/DetailNavigation.tsx @@ -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 ( )} - + + + + + + + - + diff --git a/src/frontend/src/functions/DetailNavigation.tsx b/src/frontend/src/functions/DetailNavigation.tsx index 4649b05cfa..193711b69a 100644 --- a/src/frontend/src/functions/DetailNavigation.tsx +++ b/src/frontend/src/functions/DetailNavigation.tsx @@ -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); }); diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index b5ba128893..faaaef71e7 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -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');