mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-21 20:45:13 +00:00
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:
@@ -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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
"""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:
|
Example usage in a FilterSet:
|
||||||
tags = TagsFilter(label=_('Tags'))
|
tags = TagsFilter()
|
||||||
|
|
||||||
Example query:
|
Example query:
|
||||||
?tags=apple,banana → returns only items tagged with both 'apple' AND 'banana'
|
?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."""
|
"""Initialize the filter."""
|
||||||
if 'label' not in kwargs:
|
if 'label' not in kwargs:
|
||||||
kwargs['label'] = _('Tags')
|
kwargs['label'] = _('Tags')
|
||||||
|
|
||||||
|
self._is_viewset = is_viewset
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
"""Filter queryset to items matching all provided tag names."""
|
"""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
|
return qs
|
||||||
|
|
||||||
tag_names = [t.strip() for t in value.split(',') if t.strip()]
|
tag_names = [t.strip() for t in value.split(',') if t.strip()]
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ class ManufacturerPartFilter(FilterSet):
|
|||||||
field_name='manufacturer__active', label=_('Manufacturer is Active')
|
field_name='manufacturer__active', label=_('Manufacturer is Active')
|
||||||
)
|
)
|
||||||
|
|
||||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
tags = common.filters.TagsFilter()
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerOutputOptions(OutputConfiguration):
|
class ManufacturerOutputOptions(OutputConfiguration):
|
||||||
@@ -308,7 +308,7 @@ class SupplierPartFilter(FilterSet):
|
|||||||
else:
|
else:
|
||||||
return queryset.exclude(in_stock__gt=0)
|
return queryset.exclude(in_stock__gt=0)
|
||||||
|
|
||||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
tags = common.filters.TagsFilter()
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartOutputOptions(OutputConfiguration):
|
class SupplierPartOutputOptions(OutputConfiguration):
|
||||||
|
|||||||
@@ -374,6 +374,8 @@ class PurchaseOrderFilter(OrderFilter):
|
|||||||
"""
|
"""
|
||||||
return queryset.filter(lines__build_order=build).distinct()
|
return queryset.filter(lines__build_order=build).distinct()
|
||||||
|
|
||||||
|
tags = common.filters.TagsFilter(is_viewset=True)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderOutputOptions(OutputConfiguration):
|
class PurchaseOrderOutputOptions(OutputConfiguration):
|
||||||
"""Output options for the PurchaseOrder endpoint."""
|
"""Output options for the PurchaseOrder endpoint."""
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.filter({'supplier_part': 3}, 2)
|
self.filter({'supplier_part': 3}, 2)
|
||||||
self.filter({'supplier_part': 4}, 0)
|
self.filter({'supplier_part': 4}, 0)
|
||||||
|
|
||||||
|
# Filter by "tags"
|
||||||
|
self.filter({'tags': True}, 7)
|
||||||
|
|
||||||
def test_total_price(self):
|
def test_total_price(self):
|
||||||
"""Unit tests for the 'total_price' field."""
|
"""Unit tests for the 'total_price' field."""
|
||||||
# Ensure we have exchange rate data
|
# Ensure we have exchange rate data
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ class StockLocationFilter(FilterSet):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
tags = common.filters.TagsFilter()
|
||||||
|
|
||||||
|
|
||||||
class StockLocationMixin(SerializerContextMixin):
|
class StockLocationMixin(SerializerContextMixin):
|
||||||
@@ -1067,7 +1067,7 @@ class StockFilter(FilterSet):
|
|||||||
children = loc_obj.getUniqueChildren()
|
children = loc_obj.getUniqueChildren()
|
||||||
return queryset.filter(location__in=children)
|
return queryset.filter(location__in=children)
|
||||||
|
|
||||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
tags = common.filters.TagsFilter()
|
||||||
|
|
||||||
|
|
||||||
class StockApiMixin(SerializerContextMixin):
|
class StockApiMixin(SerializerContextMixin):
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
|
import { ActionIcon, Group, Space, Text, Tooltip } from '@mantine/core';
|
||||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
import {
|
||||||
|
IconCancel,
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { removeDetailNavigationParams } from '../../functions/DetailNavigation';
|
||||||
import type { DetailNavigationState } from '../../hooks/UseDetailNavigation';
|
import type { DetailNavigationState } from '../../hooks/UseDetailNavigation';
|
||||||
|
|
||||||
export function DetailNavigation({
|
export function DetailNavigation({
|
||||||
@@ -13,11 +19,22 @@ export function DetailNavigation({
|
|||||||
navigation.position ||
|
navigation.position ||
|
||||||
navigation.isLoading
|
navigation.isLoading
|
||||||
);
|
);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
removeDetailNavigationParams(url.searchParams);
|
||||||
|
navigate(url);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasNavigation) {
|
if (!hasNavigation) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<Group
|
<Group
|
||||||
gap={5}
|
gap={5}
|
||||||
@@ -37,7 +54,18 @@ export function DetailNavigation({
|
|||||||
{t`${navigation.position.current} of ${navigation.position.total}`}
|
{t`${navigation.position.current} of ${navigation.position.total}`}
|
||||||
</Text>
|
</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
|
<ActionIcon
|
||||||
component='a'
|
component='a'
|
||||||
href={navigation.previous?.href}
|
href={navigation.previous?.href}
|
||||||
@@ -45,12 +73,12 @@ export function DetailNavigation({
|
|||||||
disabled={!navigation.previous}
|
disabled={!navigation.previous}
|
||||||
size='md'
|
size='md'
|
||||||
variant='subtle'
|
variant='subtle'
|
||||||
aria-label={t`Previous`}
|
aria-label={lbl_prev}
|
||||||
>
|
>
|
||||||
<IconChevronLeft size='1.25rem' />
|
<IconChevronLeft size='1.25rem' />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t`Next`} position='top'>
|
<Tooltip label={lbl_next} position='top'>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component='a'
|
component='a'
|
||||||
href={navigation.next?.href}
|
href={navigation.next?.href}
|
||||||
@@ -58,7 +86,7 @@ export function DetailNavigation({
|
|||||||
disabled={!navigation.next}
|
disabled={!navigation.next}
|
||||||
size='md'
|
size='md'
|
||||||
variant='subtle'
|
variant='subtle'
|
||||||
aria-label={t`Next`}
|
aria-label={lbl_next}
|
||||||
>
|
>
|
||||||
<IconChevronRight size='1.25rem' />
|
<IconChevronRight size='1.25rem' />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function decodeDetailNavigationApi(apiUrl: string): string {
|
|||||||
return DETAIL_NAVIGATION_API_URLS.get(apiUrl) ?? apiUrl;
|
return DETAIL_NAVIGATION_API_URLS.get(apiUrl) ?? apiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDetailNavigationParams(params: URLSearchParams) {
|
export function removeDetailNavigationParams(params: URLSearchParams) {
|
||||||
DETAIL_NAVIGATION_PARAM_KEYS.forEach((key) => {
|
DETAIL_NAVIGATION_PARAM_KEYS.forEach((key) => {
|
||||||
params.delete(key);
|
params.delete(key);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -243,13 +243,13 @@ test('Tables - Detail navigation', async ({ browser }) => {
|
|||||||
const detailNavigation = breadcrumbBar.getByTestId('detail-navigation');
|
const detailNavigation = breadcrumbBar.getByTestId('detail-navigation');
|
||||||
await expect(detailNavigation).toBeVisible();
|
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).toBeVisible();
|
||||||
await expect(previous.locator('svg')).toBeVisible();
|
await expect(previous.locator('svg')).toBeVisible();
|
||||||
await expect(previous).toHaveAttribute('data-disabled', 'true');
|
await expect(previous).toHaveAttribute('data-disabled', 'true');
|
||||||
await expect(previous).not.toHaveAttribute('href');
|
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).toBeVisible();
|
||||||
await expect(next.locator('svg')).toBeVisible();
|
await expect(next.locator('svg')).toBeVisible();
|
||||||
await expect(next).not.toHaveAttribute('data-disabled');
|
await expect(next).not.toHaveAttribute('data-disabled');
|
||||||
|
|||||||
Reference in New Issue
Block a user