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:
Jɤ KέϞϞψ
2026-08-19 17:01:51 +10:00
committed by GitHub
co-authored by Matthias Mair Oliver
parent 8f1bb69eba
commit 16ca6c245f
11 changed files with 873 additions and 77 deletions
@@ -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,41 +40,44 @@ export function BreadcrumbList({
}, [breadcrumbs]);
return (
<Paper p='7' radius='xs' shadow='xs'>
<Group gap='xs'>
{navCallback && (
<ActionIcon
key='nav-breadcrumb-action'
aria-label='nav-breadcrumb-action'
onClick={navCallback}
variant='transparent'
>
<IconMenu2 />
</ActionIcon>
)}
<Breadcrumbs key='breadcrumbs' separator='>'>
{elements.map((breadcrumb, index) => {
return (
<Anchor
key={`${index}-${breadcrumb.name}`}
href={`/${baseUrl}${breadcrumb.url}`}
aria-label={`breadcrumb-${index}-${identifierString(
breadcrumb.name
)}`}
onClick={(event: any) =>
breadcrumb.url &&
navigateToLink(breadcrumb.url, navigate, event)
}
>
<Group gap={4}>
{breadcrumb.icon}
<Text size='sm'>{breadcrumb.name}</Text>
</Group>
</Anchor>
);
})}
</Breadcrumbs>
</Group>
</Paper>
<Group
gap='xs'
wrap='nowrap'
align='center'
style={{ flex: 1, minWidth: 0 }}
>
{navCallback && (
<ActionIcon
key='nav-breadcrumb-action'
aria-label='nav-breadcrumb-action'
onClick={navCallback}
variant='transparent'
>
<IconMenu2 />
</ActionIcon>
)}
<Breadcrumbs key='breadcrumbs' separator='>'>
{elements.map((breadcrumb, index) => {
return (
<Anchor
key={`${index}-${breadcrumb.name}`}
href={`/${baseUrl}${breadcrumb.url}`}
aria-label={`breadcrumb-${index}-${identifierString(
breadcrumb.name
)}`}
onClick={(event: any) =>
breadcrumb.url &&
navigateToLink(breadcrumb.url, navigate, event)
}
>
<Group gap={4} align='center'>
{breadcrumb.icon}
<Text size='sm'>{breadcrumb.name}</Text>
</Group>
</Anchor>
);
})}
</Breadcrumbs>
</Group>
);
}
@@ -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>
);
}
+11 -9
View File
@@ -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
};
}
+1 -1
View File
@@ -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();
});
+155
View File
@@ -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, {