Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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 @@ -593,6 +593,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
48 changes: 45 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,52 @@ 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 DISPLAY_PARAMS = new Set([
Comment thread
matmair marked this conversation as resolved.
Outdated
'part_detail',
'location_detail',
'supplier_part_detail',
'path_detail',
'tags',
'limit',
'offset'
]);

const navCtx = encodeNavContext({
endpoint: url,
filters: Object.fromEntries(
Object.entries(queryFilters)
.filter(
([k, v]) =>
k !== 'ordering' &&
k !== 'search' &&
!DISPLAY_PARAMS.has(k) &&
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
};
}
Loading