From fcb7b28ff73cac22e11d1fa86e3fe13613a4b63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=AF=E5=A1=9A=E9=99=BD=E5=AD=90?= Date: Sat, 15 Aug 2026 23:17:16 +0000 Subject: [PATCH] Add previous and next detail navigation --- src/frontend/lib/functions/Navigation.tsx | 27 ++++++++ .../src/components/nav/PageDetail.tsx | 61 ++++++++++++++++++- .../src/components/tables/InvenTreeTable.tsx | 25 ++++++-- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/frontend/lib/functions/Navigation.tsx b/src/frontend/lib/functions/Navigation.tsx index 939dd0211522..60fd2312a100 100644 --- a/src/frontend/lib/functions/Navigation.tsx +++ b/src/frontend/lib/functions/Navigation.tsx @@ -59,6 +59,33 @@ export function getDetailUrl( return ''; } +/** + * Add URL-persisted list context for previous/next navigation on a detail page. + */ +export function getDetailUrlWithNavigation( + model: ModelType, + pk: number | string, + endpoint: string, + filters: Record, + index: number, + absolute?: boolean +): string { + const detailUrl = getDetailUrl(model, pk, absolute); + if (!detailUrl || !endpoint || index < 0) return detailUrl; + + const query = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + query.set(key, String(value)); + } + }); + + const separator = detailUrl.includes('?') ? '&' : '?'; + return `${detailUrl}${separator}_navApi=${encodeURIComponent(endpoint)}&${ + `_nav=${encodeURIComponent(query.toString())}&_navIndex=${index}` + }`; +} + /** * Returns the API detail URL for a given model type. */ diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 915f37388d81..ca50a64a5a7e 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -1,11 +1,14 @@ -import { Group, Paper, Space, Stack, Text } from '@mantine/core'; +import { ActionIcon, Group, Paper, Space, Stack, Text, Tooltip } from '@mantine/core'; import { StylishText } from '@lib/components/StylishText'; import { useInvenTreeHotkeys } from '@lib/functions/Events'; import { shortenString } from '@lib/functions/String'; import { t } from '@lingui/core/macro'; +import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; import { Fragment, type ReactNode, useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { useApi } from '../../contexts/ApiContext'; import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature'; import { useUserSettingsState } from '../../states/SettingsStates'; import PrimaryActionButton from '../buttons/PrimaryActionButton'; @@ -30,6 +33,59 @@ interface PageDetailInterface { editEnabled?: boolean; } +function DetailNavigation() { + const api = useApi(); + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const endpoint = searchParams.get('_navApi'); + const encodedFilters = searchParams.get('_nav'); + const currentIndex = Number(searchParams.get('_navIndex')); + const currentPk = location.pathname.split('/').filter(Boolean).at(-1); + + const query = useQuery({ + enabled: !!endpoint && encodedFilters !== null && Number.isInteger(currentIndex) && currentIndex >= 0, + queryKey: ['detail-navigation', endpoint, encodedFilters, currentIndex], + queryFn: async () => { + const params = new URLSearchParams(encodedFilters ?? ''); + params.set('limit', '3'); + params.set('offset', String(Math.max(0, currentIndex - 1))); + const response = await api.get(endpoint!, { params }); + return response.data?.results ?? response.data ?? []; + } + }); + + if (!Array.isArray(query.data)) return null; + const localIndex = query.data.findIndex((record: any) => String(record.pk) === String(currentPk)); + if (localIndex < 0) return null; + + const navigateToRecord = (record: any, offset: number) => { + const params = new URLSearchParams(searchParams); + params.set('_navIndex', String(offset)); + const path = location.pathname.replace(/[^/]+\/?$/, `${record.pk}/`); + navigate(`${path}?${params.toString()}`); + }; + const previous = localIndex > 0 ? query.data[localIndex - 1] : undefined; + const next = localIndex + 1 < query.data.length ? query.data[localIndex + 1] : undefined; + + return ( + + + previous && navigateToRecord(previous, currentIndex - 1)}> + + + + + next && navigateToRecord(next, currentIndex + 1)}> + + + + + ); +} + /** * Construct a "standard" page detail for common display between pages. * @@ -184,6 +240,7 @@ export function PageDetail({ )} + {computedActions && ( {computedActions.map((action, idx) => ( diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 15ef3cde1d5b..2094db03ebcd 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -5,7 +5,10 @@ import { ModelInformationDict } from '@lib/enums/ModelInformation'; import { resolveItem } from '@lib/functions/Conversion'; import { cancelEvent } from '@lib/functions/Events'; import { mapFields } from '@lib/functions/Forms'; -import { eventModified, getDetailUrl } from '@lib/functions/Navigation'; +import { + eventModified, + getDetailUrlWithNavigation +} from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation'; import { useStoredTableState } from '@lib/states/StoredTableState'; import type { TableFilter } from '@lib/types/Filters'; @@ -748,10 +751,16 @@ export function InvenTreeTableInternal>({ 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 = getDetailUrlWithNavigation( + tableProps.modelType, + pk, + url ?? '', + getTableFilters(false), + (tableState.page - 1) * pageSize + index + ); if (!showPreviewPanel || eventModified(event as any)) { - navigateToLink(url, navigate, event); + navigateToLink(detailUrl, navigate, event); } else { showRowPreview(pk); } @@ -802,7 +811,13 @@ export function InvenTreeTableInternal>({ // 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 detailUrl = getDetailUrlWithNavigation( + props.modelType, + pk, + url ?? '', + getTableFilters(false), + (tableState.page - 1) * pageSize + (tableState.records as any[]).indexOf(record) + ); const model: string | undefined = ModelInformationDict[props.modelType]?.label?.(); @@ -820,7 +835,7 @@ export function InvenTreeTableInternal>({ onClick: (event: any) => { cancelEvent(event); if (!showPreviewPanel || eventModified(event as any)) { - navigateToLink(url, navigate, event); + navigateToLink(detailUrl, navigate, event); } else { showRowPreview(pk); }