diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index e4602695e099..a4f808a46623 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 530 +INVENTREE_API_VERSION = 531 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v531 -> 2026-08-03 : https://github.com/inventree/InvenTree/pull/12528 + - Adds pk_gt and pk_lt filter fields to the Part and StockItem API endpoints + v530 -> 2026-07-28 : https://github.com/inventree/InvenTree/pull/12487 - Adds searching against ReportSnippet API endpoint diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index a773440c26af..0cf7ab908ea3 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -672,6 +672,13 @@ class Meta: label=_('Is Variant'), method='filter_is_variant' ) + pk_gt = rest_filters.NumberFilter( + field_name='pk', lookup_expr='gt', label=_('PK greater than') + ) + pk_lt = rest_filters.NumberFilter( + field_name='pk', lookup_expr='lt', label=_('PK less than') + ) + def filter_is_variant(self, queryset, name, value): """Filter by whether the Part is a variant or not.""" return queryset.filter(variant_of__isnull=not str2bool(value)) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 994d039c8530..bea219d6fb7c 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -601,6 +601,13 @@ class Meta: 'tags__slug', ] + pk_gt = rest_filters.NumberFilter( + field_name='pk', lookup_expr='gt', label='PK greater than' + ) + pk_lt = rest_filters.NumberFilter( + field_name='pk', lookup_expr='lt', label='PK less than' + ) + # Relationship filters manufacturer = rest_filters.ModelChoiceFilter( label='Manufacturer', diff --git a/src/frontend/src/components/nav/NextPrevAction.tsx b/src/frontend/src/components/nav/NextPrevAction.tsx new file mode 100644 index 000000000000..e4612c166064 --- /dev/null +++ b/src/frontend/src/components/nav/NextPrevAction.tsx @@ -0,0 +1,42 @@ +import { t } from '@lingui/core/macro'; +import { ActionIcon, Group, Tooltip } from '@mantine/core'; +import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; + +interface NextPrevActionProps { + prevPk?: number; + nextPk?: number; + onPrev: () => void; + onNext: () => void; +} + +export function NextPrevAction({ + prevPk, + nextPk, + onPrev, + onNext +}: Readonly) { + return ( + + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 915f37388d81..03ca70d28742 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -5,7 +5,8 @@ import { useInvenTreeHotkeys } from '@lib/functions/Events'; 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 { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useNextPrevSiblings } from '../../hooks/UseNextPrev'; import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature'; import { useUserSettingsState } from '../../states/SettingsStates'; import PrimaryActionButton from '../buttons/PrimaryActionButton'; @@ -13,6 +14,7 @@ import { ApiImage } from '../images/ApiImage'; import { ApiIcon } from '../items/ApiIcon'; import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes'; import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList'; +import { NextPrevAction } from './NextPrevAction'; import PageTitle from './PageTitle'; interface PageDetailInterface { @@ -28,6 +30,7 @@ interface PageDetailInterface { actions?: ReactNode[]; editAction?: () => void; editEnabled?: boolean; + pk?: number; } /** @@ -48,11 +51,32 @@ export function PageDetail({ breadcrumbAction, actions, editAction, - editEnabled + editEnabled, + pk }: Readonly) { const userSettings = useUserSettingsState(); const navigate = useNavigate(); const location = useLocation(); + const params = useParams(); + // Routes use `:id/*` — the panel is in the splat, not a named param + const currentPanel = params['*'] ? params['*'].replace(/\/$/, '') : undefined; + + const { prevPk, nextPk, navParams } = useNextPrevSiblings(pk); + + const navigateWithNav = (targetPk: number) => { + let base: string; + if (currentPanel) { + // Path is like /web/stock/item/501/stock-details/ — strip both trailing segments + base = location.pathname.replace( + /\/[^/]+\/[^/]+\/?$/, + `/${targetPk}/${currentPanel}/` + ); + } else { + base = location.pathname.replace(/\/[^/]+\/?$/, `/${targetPk}/`); + } + const qs = new URLSearchParams(navParams).toString(); + navigate(qs ? `${base}?${qs}` : base); + }; useInvenTreeHotkeys([ [ @@ -184,9 +208,17 @@ export function PageDetail({ )} - {computedActions && ( + {(computedActions || prevPk || nextPk) && ( - {computedActions.map((action, idx) => ( + {(prevPk || nextPk) && ( + navigateWithNav(prevPk!)} + onNext={() => navigateWithNav(nextPk!)} + /> + )} + {computedActions?.map((action, idx) => ( {action} ))} diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index 6dc549270d6f..e48e55451bc5 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -304,10 +304,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 +508,7 @@ function IndexPanelComponent({ defaultPanel, panels }: Readonly) { + const location = useLocation(); const lastUsedPanel = useLocalState( useShallow((state) => { const panelName = @@ -527,7 +528,7 @@ function IndexPanelComponent({ }) ); - return ; + return ; } /** diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 15ef3cde1d5b..488dd9f67253 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -36,6 +36,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useApi } from '../../contexts/ApiContext'; import { extractAvailableFields } from '../../functions/forms'; import { showApiErrorMessage } from '../../functions/notifications'; +import { encodeNavContext } from '../../hooks/UseNextPrev'; import { useLocalState } from '../../states/LocalState'; import { usePreviewDrawerState } from '../../states/PreviewDrawerState'; import { useUserSettingsState } from '../../states/SettingsStates'; @@ -747,11 +748,45 @@ 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 = getDetailUrl(tableProps.modelType, pk); if (!showPreviewPanel || eventModified(event as any)) { - navigateToLink(url, navigate, event); + // Build nav context from current table state so the detail page + // can resolve prev/next against the same filtered/ordered set. + if (url) { + const queryFilters = getTableFilters(false); + const ordering = queryFilters.ordering ?? ''; + + const navCtx = encodeNavContext({ + endpoint: url, + filters: Object.fromEntries( + Object.entries(queryFilters) + .filter( + ([k, v]) => + k !== 'ordering' && + k !== 'search' && + k !== 'limit' && + k !== 'offset' && + k !== 'tags' && + !k.endsWith('_detail') && + v !== undefined && + v !== null && + typeof v !== 'object' && + v !== 'undefined' && + v !== 'null' + ) + .map(([k, v]) => [k, String(v)]) + ), + ordering: ordering || 'pk' + }); + if (queryFilters.search) { + navCtx['_nav_search'] = String(queryFilters.search); + } + const qs = new URLSearchParams(navCtx).toString(); + navigateToLink(`${detailUrl}?${qs}`, navigate, event); + } else { + navigateToLink(detailUrl, navigate, event); + } } else { showRowPreview(pk); } diff --git a/src/frontend/src/hooks/UseNextPrev.tsx b/src/frontend/src/hooks/UseNextPrev.tsx new file mode 100644 index 000000000000..ea59d6fb339a --- /dev/null +++ b/src/frontend/src/hooks/UseNextPrev.tsx @@ -0,0 +1,123 @@ +import { apiUrl } from '@lib/functions/Api'; +import { useQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import { useApi } from '../contexts/ApiContext'; + +export const NAV_PARAM_PREFIX = '_nav_'; + +export interface NavContext { + endpoint: string; + filters: Record; + ordering: string; +} + +export function encodeNavContext(ctx: NavContext): Record { + const out: Record = { + [`${NAV_PARAM_PREFIX}endpoint`]: ctx.endpoint, + [`${NAV_PARAM_PREFIX}ordering`]: ctx.ordering + }; + for (const [k, v] of Object.entries(ctx.filters)) { + out[`${NAV_PARAM_PREFIX}f_${k}`] = v; + } + return out; +} + +function decodeNavContext(params: URLSearchParams): NavContext | null { + const endpoint = params.get(`${NAV_PARAM_PREFIX}endpoint`); + const ordering = params.get(`${NAV_PARAM_PREFIX}ordering`) ?? 'pk'; + + if (!endpoint) return null; + + const filters: Record = {}; + for (const [k, v] of params.entries()) { + if ( + k.startsWith(`${NAV_PARAM_PREFIX}f_`) && + v !== 'undefined' && + v !== 'null' && + v !== '' + ) { + filters[k.slice(`${NAV_PARAM_PREFIX}f_`.length)] = v; + } + } + + return { endpoint, filters, ordering }; +} + +interface UseNextPrevSiblingsResult { + prevPk?: number; + nextPk?: number; + isLoading: boolean; + navParams: Record; +} + +export function useNextPrevSiblings( + currentPk: number | undefined +): UseNextPrevSiblingsResult { + const api = useApi(); + const [searchParams] = useSearchParams(); + const ctx = decodeNavContext(searchParams); + + const navParams: Record = {}; + if (ctx) { + for (const [k, v] of searchParams.entries()) { + if (k.startsWith(NAV_PARAM_PREFIX)) { + navParams[k] = v; + } + } + } + + const enabled = !!currentPk && !!ctx; + + const { data: prevPk, isLoading: prevLoading } = useQuery({ + queryKey: [ + 'next-prev', + ctx?.endpoint, + currentPk, + 'prev', + ctx?.filters, + ctx?.ordering + ], + queryFn: async () => { + const response = await api.get(apiUrl(ctx!.endpoint), { + params: { + ...ctx!.filters, + pk_lt: currentPk, + ordering: `-${ctx!.ordering.replace(/^-/, '')}`, + limit: 1 + } + }); + return response.data?.results?.[0]?.pk as number | undefined; + }, + enabled + }); + + const { data: nextPk, isLoading: nextLoading } = useQuery({ + queryKey: [ + 'next-prev', + ctx?.endpoint, + currentPk, + 'next', + ctx?.filters, + ctx?.ordering + ], + queryFn: async () => { + const response = await api.get(apiUrl(ctx!.endpoint), { + params: { + ...ctx!.filters, + pk_gt: currentPk, + ordering: ctx!.ordering, + limit: 1 + } + }); + return response.data?.results?.[0]?.pk as number | undefined; + }, + enabled + }); + + return { + prevPk, + nextPk, + isLoading: prevLoading || nextLoading, + navParams + }; +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 2bfcf93db94a..e7881514b532 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -862,6 +862,7 @@ export default function PartDetail() { editAction={editPart.open} editEnabled={user.hasChangeRole(UserRoles.part)} actions={partActions} + pk={part?.pk} /> { + const page = await doCachedLogin(browser); + + // Navigate to part pk=5 with nav context — middle of sequential range so both buttons appear + const navParams = '_nav_endpoint=%2Fapi%2Fpart%2F&_nav_ordering=pk'; + await navigate(page, `part/5/?${navParams}`); + await page.waitForLoadState('networkidle'); + + const prevBtn = page.getByTestId('inventree-prev-item'); + const nextBtn = page.getByTestId('inventree-next-item'); + + // Both buttons should be visible and enabled + await expect(prevBtn).toBeVisible(); + await expect(nextBtn).toBeVisible(); + await expect(prevBtn).not.toBeDisabled(); + await expect(nextBtn).not.toBeDisabled(); + + // Click next — URL should change away from part/5 + await nextBtn.click(); + await page.waitForURL((url) => !url.pathname.includes('/part/5/')); + await page.waitForLoadState('networkidle'); + + // On the next part prev should be enabled + await expect(page.getByTestId('inventree-prev-item')).not.toBeDisabled(); + + // Click prev — URL should change + const urlBeforeBack = page.url(); + await page.getByTestId('inventree-prev-item').click(); + await page.waitForURL((url) => url.toString() !== urlBeforeBack); +});