Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
7 changes: 7 additions & 0 deletions src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions src/backend/InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
42 changes: 42 additions & 0 deletions src/frontend/src/components/nav/NextPrevAction.tsx
Original file line number Diff line number Diff line change
@@ -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<NextPrevActionProps>) {
return (
<Group gap={5}>
<Tooltip label={t`Previous`}>
<ActionIcon
data-testid='inventree-prev-item'
disabled={prevPk === undefined}
onClick={onPrev}
variant='transparent'
>
<IconChevronLeft />
</ActionIcon>
</Tooltip>
<Tooltip label={t`Next`}>
<ActionIcon
data-testid='inventree-next-item'
disabled={nextPk === undefined}
onClick={onNext}
variant='transparent'
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
</Group>
);
}
40 changes: 36 additions & 4 deletions src/frontend/src/components/nav/PageDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ 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';
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 {
Expand All @@ -28,6 +30,7 @@ interface PageDetailInterface {
actions?: ReactNode[];
editAction?: () => void;
editEnabled?: boolean;
pk?: number;
}

/**
Expand All @@ -48,11 +51,32 @@ export function PageDetail({
breadcrumbAction,
actions,
editAction,
editEnabled
editEnabled,
pk
}: Readonly<PageDetailInterface>) {
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([
[
Expand Down Expand Up @@ -184,9 +208,17 @@ export function PageDetail({
</Group>
)}
</Group>
{computedActions && (
{(computedActions || prevPk || nextPk) && (
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
{computedActions.map((action, idx) => (
{(prevPk || nextPk) && (
<NextPrevAction
prevPk={prevPk}
nextPk={nextPk}
onPrev={() => navigateWithNav(prevPk!)}
onNext={() => navigateWithNav(nextPk!)}
/>
)}
{computedActions?.map((action, idx) => (
<Fragment key={idx}>{action}</Fragment>
))}
</Group>
Expand Down
7 changes: 4 additions & 3 deletions src/frontend/src/components/panels/PanelGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -508,6 +508,7 @@ function IndexPanelComponent({
defaultPanel,
panels
}: Readonly<PanelProps>) {
const location = useLocation();
const lastUsedPanel = useLocalState(
useShallow((state) => {
const panelName =
Expand All @@ -527,7 +528,7 @@ function IndexPanelComponent({
})
);

return <Navigate to={lastUsedPanel} replace />;
return <Navigate to={`${lastUsedPanel}${location.search}`} replace />;
}

/**
Expand Down
41 changes: 38 additions & 3 deletions src/frontend/src/components/tables/InvenTreeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -747,11 +748,45 @@ 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 = 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);
}
Expand Down
123 changes: 123 additions & 0 deletions src/frontend/src/hooks/UseNextPrev.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
ordering: string;
}

export function encodeNavContext(ctx: NavContext): Record<string, string> {
const out: Record<string, string> = {
[`${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<string, string> = {};
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<string, string>;
}

export function useNextPrevSiblings(
currentPk: number | undefined
): UseNextPrevSiblingsResult {
const api = useApi();
const [searchParams] = useSearchParams();
const ctx = decodeNavContext(searchParams);

const navParams: Record<string, string> = {};
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
};
}
1 change: 1 addition & 0 deletions src/frontend/src/pages/part/PartDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@ export default function PartDetail() {
editAction={editPart.open}
editEnabled={user.hasChangeRole(UserRoles.part)}
actions={partActions}
pk={part?.pk}
/>
<PanelGroup
pageKey='part'
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/pages/stock/StockDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ export default function StockDetail() {
setTreeOpen(true);
}}
actions={stockActions}
pk={stockitem?.pk}
/>
<PanelGroup
pageKey='stockitem'
Expand Down
Loading
Loading