mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-19 19:49:48 +00:00
feat(frontend): Add persistant filtered detail navigation in the url (#12585)
* Add filtered detail navigation * Satisfy navigation lint checks * Fix missing Fragment import in PageDetail * Address detail navigation review feedback * Move detail navigation into breadcrumb bar * Address URL and layout review feedback * Address detail navigation review feedback * fix doclinks * test: update stock return validation assertion * fix: keep detail navigation actions stable * style: apply biome formatting * fix: use ActionIcon disabled state * test: use ActionIcon disabled state * fix: isolate detail navigation query params --------- Co-authored-by: Matthias Mair <code@mjmair.com> Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
co-authored by
Matthias Mair
Oliver
parent
8f1bb69eba
commit
16ca6c245f
@@ -24,8 +24,8 @@ From the main menu, users can access the following items:
|
||||
- [Dashboard](#dashboard)
|
||||
- [Global Search](./global_search.md)
|
||||
- [Spotlight](#spotlight)
|
||||
- [Barcode Scanning](#barcode-scanning)
|
||||
- [Notifications](#notifications)
|
||||
- [Barcode Scanning](../../barcodes/index.md#quick-scan)
|
||||
- [Notifications](../../part/notification.md)
|
||||
- [User Menu](#user-menu)
|
||||
|
||||
As well as allowing navigation to the following main sections:
|
||||
@@ -84,6 +84,14 @@ On some pages, a breadcrumb navigation trail is provided at the top of the page,
|
||||
|
||||
{{ image("concepts/ui_breadcrumbs.png", "Breadcrumb Navigation") }}
|
||||
|
||||
#### Filtered Detail Navigation
|
||||
|
||||
When a detail page is opened from a table that supports detail navigation, the breadcrumb bar can show the current position in the table result set together with Previous and Next buttons. The navigation uses the table's active filters and sort order, so moving between records follows the same list the user was viewing.
|
||||
|
||||
The navigation context is stored in the detail URL. This makes links shareable and allows the same filtered result set to be restored when the link is opened in a fresh browser context. The position indicator uses the full result count, while the buttons are hidden at the first and last records.
|
||||
|
||||
The navigation controls also work with detail pages provided by frontend plugins that use the standard table and page components.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Breadcrumbs,
|
||||
Group,
|
||||
Paper,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { ActionIcon, Anchor, Breadcrumbs, Group, Text } from '@mantine/core';
|
||||
import { IconMenu2 } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -47,8 +40,12 @@ export function BreadcrumbList({
|
||||
}, [breadcrumbs]);
|
||||
|
||||
return (
|
||||
<Paper p='7' radius='xs' shadow='xs'>
|
||||
<Group gap='xs'>
|
||||
<Group
|
||||
gap='xs'
|
||||
wrap='nowrap'
|
||||
align='center'
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{navCallback && (
|
||||
<ActionIcon
|
||||
key='nav-breadcrumb-action'
|
||||
@@ -73,7 +70,7 @@ export function BreadcrumbList({
|
||||
navigateToLink(breadcrumb.url, navigate, event)
|
||||
}
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Group gap={4} align='center'>
|
||||
{breadcrumb.icon}
|
||||
<Text size='sm'>{breadcrumb.name}</Text>
|
||||
</Group>
|
||||
@@ -82,6 +79,5 @@ export function BreadcrumbList({
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import type { DetailNavigationState } from '../../hooks/UseDetailNavigation';
|
||||
|
||||
export function DetailNavigation({
|
||||
navigation
|
||||
}: Readonly<{ navigation: DetailNavigationState }>) {
|
||||
const hasNavigation = Boolean(
|
||||
navigation.previous ||
|
||||
navigation.next ||
|
||||
navigation.position ||
|
||||
navigation.isLoading
|
||||
);
|
||||
|
||||
if (!hasNavigation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={5}
|
||||
justify='right'
|
||||
wrap='nowrap'
|
||||
align='center'
|
||||
data-testid='detail-navigation'
|
||||
style={{ flexShrink: 0, minHeight: 36 }}
|
||||
>
|
||||
{navigation.position && (
|
||||
<Text
|
||||
size='xs'
|
||||
c='dimmed'
|
||||
px={4}
|
||||
aria-label='detail-navigation-position'
|
||||
>
|
||||
{t`${navigation.position.current} of ${navigation.position.total}`}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip label={t`Previous`} position='top'>
|
||||
<ActionIcon
|
||||
component='a'
|
||||
href={navigation.previous?.href}
|
||||
onClick={navigation.previous?.onClick}
|
||||
disabled={!navigation.previous}
|
||||
size='md'
|
||||
variant='subtle'
|
||||
aria-label={t`Previous`}
|
||||
>
|
||||
<IconChevronLeft size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t`Next`} position='top'>
|
||||
<ActionIcon
|
||||
component='a'
|
||||
href={navigation.next?.href}
|
||||
onClick={navigation.next?.onClick}
|
||||
disabled={!navigation.next}
|
||||
size='md'
|
||||
variant='subtle'
|
||||
aria-label={t`Next`}
|
||||
>
|
||||
<IconChevronRight size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,15 @@ import { shortenString } from '@lib/functions/String';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useDetailNavigation } from '../../hooks/UseDetailNavigation';
|
||||
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import PrimaryActionButton from '../buttons/PrimaryActionButton';
|
||||
import { ApiImage } from '../images/ApiImage';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes';
|
||||
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||
import type { Breadcrumb } from './BreadcrumbList';
|
||||
import { PageDetailNavigationBar } from './PageDetailNavigationBar';
|
||||
import PageTitle from './PageTitle';
|
||||
|
||||
interface PageDetailInterface {
|
||||
@@ -53,6 +55,7 @@ export function PageDetail({
|
||||
const userSettings = useUserSettingsState();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const detailNavigation = useDetailNavigation();
|
||||
|
||||
useInvenTreeHotkeys([
|
||||
[
|
||||
@@ -134,12 +137,11 @@ export function PageDetail({
|
||||
<>
|
||||
<PageTitle title={pageTitleString} />
|
||||
<Stack gap='xs'>
|
||||
{computedBreadcrumbs && computedBreadcrumbs.length > 0 && (
|
||||
<BreadcrumbList
|
||||
navCallback={breadcrumbAction}
|
||||
breadcrumbs={computedBreadcrumbs}
|
||||
<PageDetailNavigationBar
|
||||
breadcrumbAction={breadcrumbAction}
|
||||
breadcrumbs={computedBreadcrumbs ?? []}
|
||||
detailNavigation={detailNavigation}
|
||||
/>
|
||||
)}
|
||||
<Paper p='xs' radius='xs' shadow='xs'>
|
||||
<Group
|
||||
justify='space-between'
|
||||
@@ -184,8 +186,8 @@ export function PageDetail({
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{computedActions && (
|
||||
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
|
||||
{computedActions.length > 0 && (
|
||||
<Group gap={5} justify='right' wrap='nowrap' align='center'>
|
||||
{computedActions.map((action, idx) => (
|
||||
<Fragment key={idx}>{action}</Fragment>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Group, Paper } from '@mantine/core';
|
||||
|
||||
import type { DetailNavigationState } from '../../hooks/UseDetailNavigation';
|
||||
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||
import { DetailNavigation } from './DetailNavigation';
|
||||
|
||||
export function PageDetailNavigationBar({
|
||||
breadcrumbs,
|
||||
breadcrumbAction,
|
||||
detailNavigation
|
||||
}: Readonly<{
|
||||
breadcrumbs: Breadcrumb[];
|
||||
breadcrumbAction?: () => void;
|
||||
detailNavigation: DetailNavigationState;
|
||||
}>) {
|
||||
const hasDetailNavigation = Boolean(
|
||||
detailNavigation.previous ||
|
||||
detailNavigation.next ||
|
||||
detailNavigation.position ||
|
||||
detailNavigation.isLoading
|
||||
);
|
||||
|
||||
if (breadcrumbs.length === 0 && !hasDetailNavigation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p='7' radius='xs' shadow='xs' data-testid='breadcrumb-list'>
|
||||
<Group gap='xs' justify='space-between' align='center' wrap='nowrap'>
|
||||
<BreadcrumbList
|
||||
navCallback={breadcrumbAction}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{hasDetailNavigation && (
|
||||
<DetailNavigation navigation={detailNavigation} />
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,7 @@ function PanelTabComponent({
|
||||
onClick: (event: any) => void;
|
||||
}) {
|
||||
const visibility = useDocumentVisibility();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if we should display an indicator dot for this panel
|
||||
const notificationDot = useQuery({
|
||||
@@ -172,7 +173,9 @@ function PanelTabComponent({
|
||||
textAlign: 'left'
|
||||
}}
|
||||
href={generateUrl(
|
||||
`/${getBaseUrl()}${location.pathname}/${panel.name}`
|
||||
`/${getBaseUrl()}${location.pathname}/${panel.name}${
|
||||
location.search
|
||||
}`
|
||||
)}
|
||||
>
|
||||
{expanded && panel.label}
|
||||
@@ -304,10 +307,10 @@ function BasePanelGroup({
|
||||
}
|
||||
|
||||
if (event && eventModified(event)) {
|
||||
const url = `${location.pathname}/../${targetPanel}`;
|
||||
const url = `${location.pathname}/../${targetPanel}${location.search}`;
|
||||
navigateToLink(url, navigate, event);
|
||||
} else {
|
||||
navigate(`../${targetPanel}`);
|
||||
navigate(`../${targetPanel}${location.search}`);
|
||||
}
|
||||
|
||||
localState.setLastUsedPanel(pageKey)(targetPanel);
|
||||
@@ -508,6 +511,7 @@ function IndexPanelComponent({
|
||||
defaultPanel,
|
||||
panels
|
||||
}: Readonly<PanelProps>) {
|
||||
const location = useLocation();
|
||||
const lastUsedPanel = useLocalState(
|
||||
useShallow((state) => {
|
||||
const panelName =
|
||||
@@ -527,7 +531,7 @@ function IndexPanelComponent({
|
||||
})
|
||||
);
|
||||
|
||||
return <Navigate to={lastUsedPanel} replace />;
|
||||
return <Navigate to={`${lastUsedPanel}${location.search}`} replace />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,12 @@ import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import {
|
||||
buildDetailNavigationUrl,
|
||||
excludeDetailNavigationParams,
|
||||
extractDetailNavigationParams,
|
||||
isSafeApiListUrl
|
||||
} from '../../functions/DetailNavigation';
|
||||
import { extractAvailableFields } from '../../functions/forms';
|
||||
import { showApiErrorMessage } from '../../functions/notifications';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
@@ -114,6 +120,20 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
return userSettings.isSet('STICKY_TABLE_HEADER');
|
||||
}, [userSettings]);
|
||||
|
||||
const tableSearchParams = useMemo(() => {
|
||||
return searchParams
|
||||
? excludeDetailNavigationParams(searchParams)
|
||||
: undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const clearTableSearchParams = useCallback(() => {
|
||||
setSearchParams?.(
|
||||
searchParams
|
||||
? extractDetailNavigationParams(searchParams)
|
||||
: new URLSearchParams()
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Key used for caching table data
|
||||
const cacheKey = useMemo(() => {
|
||||
const key: string = `tbl-${tableState.tableKey}`;
|
||||
@@ -455,7 +475,11 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
useEffect(() => {
|
||||
tableState.setPage(1);
|
||||
tableState.clearSelectedRecords();
|
||||
}, [tableState.searchTerm, tableState.filterSet.activeFilters, searchParams]);
|
||||
}, [
|
||||
tableState.searchTerm,
|
||||
tableState.filterSet.activeFilters,
|
||||
tableSearchParams
|
||||
]);
|
||||
|
||||
// Account for invalid page offsets
|
||||
useEffect(() => {
|
||||
@@ -489,9 +513,9 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
...tableProps.params
|
||||
};
|
||||
|
||||
if (searchParams && searchParams.size > 0) {
|
||||
if (tableSearchParams && tableSearchParams.size > 0) {
|
||||
// Allow override of filters based on URL query parameters
|
||||
for (const [key, value] of searchParams) {
|
||||
for (const [key, value] of tableSearchParams) {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
} else if (tableState.filterSet.activeFilters) {
|
||||
@@ -530,12 +554,55 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
tableProps.params,
|
||||
tableProps.enablePagination,
|
||||
tableState.filterSet.activeFilters,
|
||||
searchParams,
|
||||
tableSearchParams,
|
||||
tableState.searchTerm,
|
||||
getOrderingTerm
|
||||
]
|
||||
);
|
||||
|
||||
const getDetailNavigationUrl = useCallback(
|
||||
(record: any, index: number): string | undefined => {
|
||||
if (
|
||||
!tableProps.modelType ||
|
||||
!url ||
|
||||
!isSafeApiListUrl(url) ||
|
||||
index < 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accessor = tableProps.modelField ?? 'pk';
|
||||
const pk = resolveItem(record, accessor);
|
||||
|
||||
if (pk === null || pk === undefined || pk === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const absoluteIndex =
|
||||
tableProps.enablePagination === false
|
||||
? index
|
||||
: (tableState.page - 1) * pageSize + index;
|
||||
|
||||
return buildDetailNavigationUrl({
|
||||
detailUrl: getDetailUrl(tableProps.modelType, pk),
|
||||
apiUrl: url,
|
||||
queryParams: getTableFilters(false),
|
||||
index: absoluteIndex,
|
||||
pk,
|
||||
field: accessor
|
||||
});
|
||||
},
|
||||
[
|
||||
getTableFilters,
|
||||
pageSize,
|
||||
tableProps.enablePagination,
|
||||
tableProps.modelField,
|
||||
tableProps.modelType,
|
||||
tableState.page,
|
||||
url
|
||||
]
|
||||
);
|
||||
|
||||
const [cacheLoaded, setCacheLoaded] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -672,7 +739,7 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
// Refetch data when the query parameters change
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [searchParams]);
|
||||
}, [tableSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const loading: boolean =
|
||||
@@ -709,9 +776,16 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
|
||||
// Callback to display "preview" view for a row (if available)
|
||||
const showRowPreview = useCallback(
|
||||
(pk: string | number) => {
|
||||
(pk: string | number, targetUrl?: string) => {
|
||||
if (tableProps.modelType && pk) {
|
||||
previewDrawer.openPreview(tableProps.modelType, Number(pk));
|
||||
previewDrawer.openPreview(
|
||||
tableProps.modelType,
|
||||
Number(pk),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
targetUrl
|
||||
);
|
||||
}
|
||||
},
|
||||
[tableProps.modelType]
|
||||
@@ -748,17 +822,26 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
if (pk) {
|
||||
cancelEvent(event);
|
||||
// If a model type is provided, navigate to the detail view for that model
|
||||
const url = getDetailUrl(tableProps.modelType, pk);
|
||||
const detailUrl =
|
||||
getDetailNavigationUrl(record, index) ??
|
||||
getDetailUrl(tableProps.modelType, pk);
|
||||
|
||||
if (!showPreviewPanel || eventModified(event as any)) {
|
||||
navigateToLink(url, navigate, event);
|
||||
navigateToLink(detailUrl, navigate, event);
|
||||
} else {
|
||||
showRowPreview(pk);
|
||||
showRowPreview(pk, detailUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[props.onRowClick, props.onCellClick, showPreviewPanel]
|
||||
[
|
||||
getDetailNavigationUrl,
|
||||
props.onCellClick,
|
||||
props.onRowClick,
|
||||
showPreviewPanel,
|
||||
showRowPreview,
|
||||
tableProps.modelType
|
||||
]
|
||||
);
|
||||
|
||||
const supportsContextMenu = useMemo(() => {
|
||||
@@ -802,7 +885,12 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
// Add action to navigate to the detail view
|
||||
const accessor = props.modelField ?? 'pk';
|
||||
const pk = resolveItem(record, accessor);
|
||||
const url = getDetailUrl(props.modelType, pk);
|
||||
const recordIndex = tableState.records.findIndex(
|
||||
(item) => String(resolveItem(item, accessor)) === String(pk)
|
||||
);
|
||||
const detailUrl =
|
||||
getDetailNavigationUrl(record, recordIndex) ??
|
||||
getDetailUrl(props.modelType, pk);
|
||||
|
||||
const model: string | undefined =
|
||||
ModelInformationDict[props.modelType]?.label?.();
|
||||
@@ -820,9 +908,9 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
onClick: (event: any) => {
|
||||
cancelEvent(event);
|
||||
if (!showPreviewPanel || eventModified(event as any)) {
|
||||
navigateToLink(url, navigate, event);
|
||||
navigateToLink(detailUrl, navigate, event);
|
||||
} else {
|
||||
showRowPreview(pk);
|
||||
showRowPreview(pk, detailUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -836,10 +924,12 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
props.modelType,
|
||||
props.detailAction,
|
||||
props.modelField,
|
||||
getDetailNavigationUrl,
|
||||
showPreviewPanel,
|
||||
showRowPreview,
|
||||
showContextMenu,
|
||||
navigate
|
||||
navigate,
|
||||
tableState.records
|
||||
]
|
||||
);
|
||||
|
||||
@@ -930,8 +1020,8 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
hasSwitchableColumns={hasSwitchableColumns}
|
||||
columns={dataColumns}
|
||||
filters={filters}
|
||||
queryFilters={searchParams}
|
||||
clearQueryFilters={() => setSearchParams?.(new URLSearchParams())}
|
||||
queryFilters={tableSearchParams}
|
||||
clearQueryFilters={clearTableSearchParams}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
</Boundary>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
export const DETAIL_NAVIGATION_PARAMS = {
|
||||
api: '_na',
|
||||
query: '_nq',
|
||||
index: '_ni',
|
||||
pk: '_np',
|
||||
field: '_nf'
|
||||
} as const;
|
||||
|
||||
const DETAIL_NAVIGATION_PARAM_KEYS = new Set<string>(
|
||||
Object.values(DETAIL_NAVIGATION_PARAMS)
|
||||
);
|
||||
const PAGINATION_PARAMS = new Set(['limit', 'offset', 'page']);
|
||||
const DEFAULT_DETAIL_NAVIGATION_FIELD = 'pk';
|
||||
|
||||
// Keep common built-in list endpoints compact while preserving the full URL
|
||||
// for plugin and otherwise unknown endpoints.
|
||||
const DETAIL_NAVIGATION_API_ALIASES = new Map<string, string>([
|
||||
[apiUrl(ApiEndpoints.part_list), 'p'],
|
||||
[apiUrl(ApiEndpoints.category_list), 'pc'],
|
||||
[apiUrl(ApiEndpoints.stock_item_list), 's'],
|
||||
[apiUrl(ApiEndpoints.stock_location_list), 'sl'],
|
||||
[apiUrl(ApiEndpoints.build_order_list), 'b'],
|
||||
[apiUrl(ApiEndpoints.build_line_list), 'bl'],
|
||||
[apiUrl(ApiEndpoints.build_item_list), 'bi'],
|
||||
[apiUrl(ApiEndpoints.company_list), 'c'],
|
||||
[apiUrl(ApiEndpoints.supplier_part_list), 'sp'],
|
||||
[apiUrl(ApiEndpoints.manufacturer_part_list), 'mp'],
|
||||
[apiUrl(ApiEndpoints.purchase_order_list), 'po'],
|
||||
[apiUrl(ApiEndpoints.purchase_order_line_list), 'pol'],
|
||||
[apiUrl(ApiEndpoints.sales_order_list), 'so'],
|
||||
[apiUrl(ApiEndpoints.sales_order_line_list), 'sol'],
|
||||
[apiUrl(ApiEndpoints.sales_order_shipment_list), 'sh'],
|
||||
[apiUrl(ApiEndpoints.return_order_list), 'ro'],
|
||||
[apiUrl(ApiEndpoints.return_order_line_list), 'rol'],
|
||||
[apiUrl(ApiEndpoints.transfer_order_list), 'to'],
|
||||
[apiUrl(ApiEndpoints.transfer_order_line_list), 'tol'],
|
||||
[apiUrl(ApiEndpoints.transfer_order_allocation_list), 'toa'],
|
||||
[apiUrl(ApiEndpoints.sales_order_allocation_list), 'soa'],
|
||||
[apiUrl(ApiEndpoints.bom_list), 'bom'],
|
||||
[apiUrl(ApiEndpoints.parameter_list), 'pa'],
|
||||
[apiUrl(ApiEndpoints.parameter_template_list), 'pt'],
|
||||
[apiUrl(ApiEndpoints.user_list), 'u'],
|
||||
[apiUrl(ApiEndpoints.group_list), 'ug'],
|
||||
[apiUrl(ApiEndpoints.project_code_list), 'pr'],
|
||||
[apiUrl(ApiEndpoints.tag_list), 'tag'],
|
||||
[apiUrl(ApiEndpoints.attachment_list), 'at'],
|
||||
[apiUrl(ApiEndpoints.machine_list), 'm']
|
||||
]);
|
||||
|
||||
const DETAIL_NAVIGATION_API_URLS = new Map(
|
||||
Array.from(DETAIL_NAVIGATION_API_ALIASES.entries()).map(([url, alias]) => [
|
||||
alias,
|
||||
url
|
||||
])
|
||||
);
|
||||
|
||||
export type DetailNavigationContext = {
|
||||
apiUrl: string;
|
||||
query: URLSearchParams;
|
||||
index: number;
|
||||
pk: string;
|
||||
field: string;
|
||||
};
|
||||
|
||||
export type DetailNavigationAction = {
|
||||
href: string;
|
||||
onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
};
|
||||
|
||||
type DetailNavigationParam = keyof typeof DETAIL_NAVIGATION_PARAMS;
|
||||
|
||||
function getDetailNavigationParam(
|
||||
params: URLSearchParams,
|
||||
key: DetailNavigationParam
|
||||
): string | null {
|
||||
return params.get(DETAIL_NAVIGATION_PARAMS[key]);
|
||||
}
|
||||
|
||||
function encodeDetailNavigationApi(apiUrl: string): string {
|
||||
return DETAIL_NAVIGATION_API_ALIASES.get(apiUrl) ?? apiUrl;
|
||||
}
|
||||
|
||||
function decodeDetailNavigationApi(apiUrl: string): string {
|
||||
return DETAIL_NAVIGATION_API_URLS.get(apiUrl) ?? apiUrl;
|
||||
}
|
||||
|
||||
function removeDetailNavigationParams(params: URLSearchParams) {
|
||||
DETAIL_NAVIGATION_PARAM_KEYS.forEach((key) => {
|
||||
params.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
function filterDetailNavigationParams(
|
||||
params: URLSearchParams,
|
||||
includeDetailNavigationParams: boolean
|
||||
): URLSearchParams {
|
||||
const filteredParams = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of params) {
|
||||
if (
|
||||
DETAIL_NAVIGATION_PARAM_KEYS.has(key) === includeDetailNavigationParams
|
||||
) {
|
||||
filteredParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredParams;
|
||||
}
|
||||
|
||||
export function excludeDetailNavigationParams(
|
||||
params: URLSearchParams
|
||||
): URLSearchParams {
|
||||
return filterDetailNavigationParams(params, false);
|
||||
}
|
||||
|
||||
export function extractDetailNavigationParams(
|
||||
params: URLSearchParams
|
||||
): URLSearchParams {
|
||||
return filterDetailNavigationParams(params, true);
|
||||
}
|
||||
|
||||
function setDetailNavigationParams(
|
||||
target: URL,
|
||||
{
|
||||
apiUrl,
|
||||
query,
|
||||
index,
|
||||
pk,
|
||||
field
|
||||
}: {
|
||||
apiUrl: string;
|
||||
query: string;
|
||||
index: number;
|
||||
pk: string | number;
|
||||
field: string;
|
||||
}
|
||||
) {
|
||||
removeDetailNavigationParams(target.searchParams);
|
||||
|
||||
target.searchParams.set(
|
||||
DETAIL_NAVIGATION_PARAMS.api,
|
||||
encodeDetailNavigationApi(apiUrl)
|
||||
);
|
||||
target.searchParams.set(DETAIL_NAVIGATION_PARAMS.index, String(index));
|
||||
target.searchParams.set(DETAIL_NAVIGATION_PARAMS.pk, String(pk));
|
||||
|
||||
if (field !== DEFAULT_DETAIL_NAVIGATION_FIELD) {
|
||||
target.searchParams.set(DETAIL_NAVIGATION_PARAMS.field, field);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
target.searchParams.set(DETAIL_NAVIGATION_PARAMS.query, query);
|
||||
}
|
||||
}
|
||||
|
||||
export function isSafeApiListUrl(url?: string): url is string {
|
||||
return !!url && url.startsWith('/api/') && !url.startsWith('//');
|
||||
}
|
||||
|
||||
function appendQueryValue(query: URLSearchParams, key: string, value: unknown) {
|
||||
if (value === null || value === undefined || PAGINATION_PARAMS.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => appendQueryValue(query, key, item));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
query.append(key, JSON.stringify(value));
|
||||
return;
|
||||
}
|
||||
|
||||
query.append(key, value.toString());
|
||||
}
|
||||
|
||||
export function serializeDetailNavigationQuery(
|
||||
params: Record<string, unknown>
|
||||
): string {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
appendQueryValue(query, key, value);
|
||||
});
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export function buildDetailNavigationUrl({
|
||||
detailUrl,
|
||||
apiUrl,
|
||||
queryParams,
|
||||
index,
|
||||
pk,
|
||||
field
|
||||
}: {
|
||||
detailUrl: string;
|
||||
apiUrl: string;
|
||||
queryParams: Record<string, unknown>;
|
||||
index: number;
|
||||
pk: string | number;
|
||||
field: string;
|
||||
}): string {
|
||||
const target = new URL(detailUrl, 'https://inventree.local');
|
||||
const query = serializeDetailNavigationQuery(queryParams);
|
||||
|
||||
setDetailNavigationParams(target, {
|
||||
apiUrl,
|
||||
query,
|
||||
index,
|
||||
pk,
|
||||
field
|
||||
});
|
||||
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
}
|
||||
|
||||
export function readDetailNavigationContext(
|
||||
search: string
|
||||
): DetailNavigationContext | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const encodedApiUrl = getDetailNavigationParam(params, 'api');
|
||||
const apiUrl = encodedApiUrl
|
||||
? decodeDetailNavigationApi(encodedApiUrl)
|
||||
: null;
|
||||
const query = getDetailNavigationParam(params, 'query') ?? '';
|
||||
const index = Number(getDetailNavigationParam(params, 'index'));
|
||||
const pk = getDetailNavigationParam(params, 'pk');
|
||||
const field =
|
||||
getDetailNavigationParam(params, 'field') ??
|
||||
DEFAULT_DETAIL_NAVIGATION_FIELD;
|
||||
|
||||
if (
|
||||
!apiUrl ||
|
||||
!isSafeApiListUrl(apiUrl) ||
|
||||
!Number.isInteger(index) ||
|
||||
index < 0 ||
|
||||
pk === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
query: new URLSearchParams(query),
|
||||
index,
|
||||
pk,
|
||||
field
|
||||
};
|
||||
}
|
||||
|
||||
export function replaceDetailPk(
|
||||
pathname: string,
|
||||
currentPk: string,
|
||||
targetPk: string | number
|
||||
): string {
|
||||
const segments = pathname.split('/');
|
||||
const currentValue = String(currentPk);
|
||||
const targetValue = encodeURIComponent(String(targetPk));
|
||||
let replaced = false;
|
||||
|
||||
const nextSegments = segments.map((segment) => {
|
||||
if (!replaced && decodeURIComponent(segment) === currentValue) {
|
||||
replaced = true;
|
||||
return targetValue;
|
||||
}
|
||||
|
||||
return segment;
|
||||
});
|
||||
|
||||
return replaced ? nextSegments.join('/') : pathname;
|
||||
}
|
||||
|
||||
export function buildDetailNavigationTarget(
|
||||
pathname: string,
|
||||
search: string,
|
||||
context: DetailNavigationContext,
|
||||
targetPk: string | number,
|
||||
targetIndex: number
|
||||
): string {
|
||||
const nextPath = replaceDetailPk(pathname, context.pk, targetPk);
|
||||
const target = new URL(`${nextPath}${search}`, 'https://inventree.local');
|
||||
|
||||
setDetailNavigationParams(target, {
|
||||
apiUrl: context.apiUrl,
|
||||
query: context.query.toString(),
|
||||
index: targetIndex,
|
||||
pk: targetPk,
|
||||
field: context.field
|
||||
});
|
||||
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { resolveItem } from '@lib/functions/Conversion';
|
||||
import { navigateToLink } from '@lib/functions/Navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
import {
|
||||
type DetailNavigationAction,
|
||||
buildDetailNavigationTarget,
|
||||
readDetailNavigationContext
|
||||
} from '../functions/DetailNavigation';
|
||||
|
||||
type DetailNavigationData = {
|
||||
records: any[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type DetailNavigationState = {
|
||||
previous?: DetailNavigationAction;
|
||||
next?: DetailNavigationAction;
|
||||
position?: {
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useDetailNavigation(): DetailNavigationState {
|
||||
const api = useApi();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const context = useMemo(
|
||||
() => readDetailNavigationContext(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
const listQuery = useQuery({
|
||||
enabled: context !== null,
|
||||
queryKey: [
|
||||
'detail-navigation',
|
||||
context?.apiUrl,
|
||||
context?.query.toString(),
|
||||
context?.index,
|
||||
context?.pk,
|
||||
context?.field
|
||||
],
|
||||
queryFn: async (): Promise<DetailNavigationData> => {
|
||||
if (!context) {
|
||||
return { records: [], total: 0 };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(context.query);
|
||||
const offset = Math.max(context.index - 1, 0);
|
||||
|
||||
params.set('limit', '3');
|
||||
params.set('offset', String(offset));
|
||||
|
||||
const response = await api.get(context.apiUrl, {
|
||||
params,
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const rawRecords = response.data?.results ?? response.data ?? [];
|
||||
const records = Array.isArray(rawRecords) ? rawRecords : [];
|
||||
const count = Number(response.data?.count);
|
||||
|
||||
return {
|
||||
records,
|
||||
total: Number.isFinite(count) && count >= 0 ? count : records.length
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const records = listQuery.data?.records ?? [];
|
||||
const total = listQuery.data?.total ?? 0;
|
||||
const currentRecordIndex = context
|
||||
? records.findIndex(
|
||||
(record: any) =>
|
||||
String(resolveItem(record, context.field)) === context.pk
|
||||
)
|
||||
: -1;
|
||||
const listOffset = context ? Math.max(context.index - 1, 0) : 0;
|
||||
|
||||
const createAction = (
|
||||
record: any,
|
||||
relativeIndex: number
|
||||
): DetailNavigationAction | undefined => {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetPk = resolveItem(record, context.field);
|
||||
if (targetPk === null || targetPk === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetIndex = listOffset + relativeIndex;
|
||||
const href = buildDetailNavigationTarget(
|
||||
location.pathname,
|
||||
location.search,
|
||||
context,
|
||||
targetPk,
|
||||
targetIndex
|
||||
);
|
||||
|
||||
return {
|
||||
href,
|
||||
onClick: (event) => navigateToLink(href, navigate, event)
|
||||
};
|
||||
};
|
||||
|
||||
if (!context || currentRecordIndex < 0) {
|
||||
return { isLoading: listQuery.isLoading };
|
||||
}
|
||||
|
||||
return {
|
||||
previous:
|
||||
currentRecordIndex > 0
|
||||
? createAction(records[currentRecordIndex - 1], currentRecordIndex - 1)
|
||||
: undefined,
|
||||
next:
|
||||
currentRecordIndex < records.length - 1
|
||||
? createAction(records[currentRecordIndex + 1], currentRecordIndex + 1)
|
||||
: undefined,
|
||||
position:
|
||||
total > context.index
|
||||
? {
|
||||
current: context.index + 1,
|
||||
total
|
||||
}
|
||||
: undefined,
|
||||
isLoading: listQuery.isLoading
|
||||
};
|
||||
}
|
||||
@@ -498,7 +498,7 @@ test('Stock - Return Items', async ({ browser }) => {
|
||||
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByRole('alert', { name: 'Form Error' }).waitFor();
|
||||
await page.getByText('Quantity must be greater than zero').first().waitFor();
|
||||
await page.getByText('This field is required.').first().waitFor();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from './baseFixtures.js';
|
||||
import { stevenuser } from './defaults.js';
|
||||
import {
|
||||
@@ -136,6 +137,160 @@ test('Tables - Pagination', async ({ browser }) => {
|
||||
await page.getByText(/1 - 25 \/ \d+/).waitFor();
|
||||
});
|
||||
|
||||
test('Tables - Navigation query parameters', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'part/category/index/parts/'
|
||||
});
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
const navigationParams = new URLSearchParams({
|
||||
_na: 'p',
|
||||
_nq: 'search=530470',
|
||||
_ni: '0',
|
||||
_np: '69',
|
||||
_nf: 'pk'
|
||||
});
|
||||
|
||||
const waitForPartList = () =>
|
||||
page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
|
||||
return (
|
||||
response.request().method() === 'GET' &&
|
||||
url.pathname === '/api/part/' &&
|
||||
url.searchParams.has('limit')
|
||||
);
|
||||
});
|
||||
|
||||
const navigationRequest = waitForPartList();
|
||||
await navigate(
|
||||
page,
|
||||
`part/category/index/parts/?${navigationParams.toString()}`
|
||||
);
|
||||
|
||||
const navigationRequestUrl = new URL((await navigationRequest).url());
|
||||
|
||||
for (const key of navigationParams.keys()) {
|
||||
expect(navigationRequestUrl.searchParams.has(key)).toBe(false);
|
||||
}
|
||||
|
||||
await expect(page.getByText('Custom table filters are active')).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(page.getByLabel('table-select-filters')).toBeEnabled();
|
||||
|
||||
await setTableChoiceFilter(page, 'Assembly', 'Yes');
|
||||
|
||||
for (const [key, value] of navigationParams) {
|
||||
expect(new URL(page.url()).searchParams.get(key)).toBe(value);
|
||||
}
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
for (const [key, value] of navigationParams) {
|
||||
expect(new URL(page.url()).searchParams.get(key)).toBe(value);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(navigationParams);
|
||||
searchParams.set('search', '530470');
|
||||
|
||||
const searchRequest = waitForPartList();
|
||||
await navigate(page, `part/category/index/parts/?${searchParams.toString()}`);
|
||||
|
||||
const searchRequestUrl = new URL((await searchRequest).url());
|
||||
expect(searchRequestUrl.searchParams.get('search')).toBe('530470');
|
||||
|
||||
for (const key of navigationParams.keys()) {
|
||||
expect(searchRequestUrl.searchParams.has(key)).toBe(false);
|
||||
}
|
||||
|
||||
await expect(page.getByRole('cell', { name: '530470210' })).toBeVisible();
|
||||
|
||||
const queryFilterAlert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: 'Custom table filters are active' });
|
||||
await expect(queryFilterAlert).toBeVisible();
|
||||
await expect(page.getByLabel('table-select-filters')).toBeDisabled();
|
||||
|
||||
await queryFilterAlert.getByRole('button').click();
|
||||
await expect(queryFilterAlert).toBeHidden();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.has('search'))
|
||||
.toBe(false);
|
||||
|
||||
for (const [key, value] of navigationParams) {
|
||||
expect(new URL(page.url()).searchParams.get(key)).toBe(value);
|
||||
}
|
||||
|
||||
await expect(page.getByLabel('table-select-filters')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('Tables - Detail navigation', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'part/category/index/parts/?search=530470'
|
||||
});
|
||||
|
||||
const firstRow = page.locator('tbody tr').first();
|
||||
await firstRow.waitFor();
|
||||
await firstRow.locator('td').nth(1).click();
|
||||
await page.waitForURL(/\/web\/part\/\d+(?:\/.*)?$/);
|
||||
|
||||
const position = page.getByText(/^\d+ of \d+$/).first();
|
||||
await expect(position).toHaveText('1 of 2');
|
||||
|
||||
const breadcrumbBar = page.getByTestId('breadcrumb-list');
|
||||
const detailNavigation = breadcrumbBar.getByTestId('detail-navigation');
|
||||
await expect(detailNavigation).toBeVisible();
|
||||
|
||||
const previous = page.getByLabel('Previous', { 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 });
|
||||
await expect(next).toBeVisible();
|
||||
await expect(next.locator('svg')).toBeVisible();
|
||||
await expect(next).not.toHaveAttribute('data-disabled');
|
||||
|
||||
const nextHref = await next.getAttribute('href');
|
||||
expect(nextHref).toContain('_na=p');
|
||||
expect(nextHref).toContain('_ni=1');
|
||||
expect(nextHref).toContain('_np=');
|
||||
expect(nextHref).not.toContain('_nav_');
|
||||
expect(nextHref).not.toContain('_nf=');
|
||||
|
||||
const initialBreadcrumbHeight = (await breadcrumbBar.boundingBox())?.height;
|
||||
|
||||
await next.click();
|
||||
await expect(position).toHaveText('2 of 2');
|
||||
|
||||
const nextBreadcrumbHeight = (await breadcrumbBar.boundingBox())?.height;
|
||||
expect(initialBreadcrumbHeight).toBeDefined();
|
||||
expect(nextBreadcrumbHeight).toBeDefined();
|
||||
expect(
|
||||
Math.abs((nextBreadcrumbHeight ?? 0) - (initialBreadcrumbHeight ?? 0))
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
await expect(previous).toBeVisible();
|
||||
await expect(previous.locator('svg')).toBeVisible();
|
||||
await expect(previous).not.toHaveAttribute('data-disabled');
|
||||
await expect(previous).toHaveAttribute('href');
|
||||
|
||||
await expect(next).toBeVisible();
|
||||
await expect(next.locator('svg')).toBeVisible();
|
||||
await expect(next).toHaveAttribute('data-disabled', 'true');
|
||||
await expect(next).not.toHaveAttribute('href');
|
||||
|
||||
await previous.click();
|
||||
await expect(position).toHaveText('1 of 2');
|
||||
await expect(previous).toHaveAttribute('data-disabled', 'true');
|
||||
await expect(previous).not.toHaveAttribute('href');
|
||||
await expect(next).not.toHaveAttribute('data-disabled');
|
||||
await expect(next).toHaveAttribute('href');
|
||||
});
|
||||
|
||||
test('Tables - Columns', async ({ browser }) => {
|
||||
// Go to the "stock list" page
|
||||
const page = await doCachedLogin(browser, {
|
||||
|
||||
Reference in New Issue
Block a user