From 8761766c53671eb01303d6aa17692da08305c9f8 Mon Sep 17 00:00:00 2001 From: jdjioe5-cpu Date: Fri, 31 Jul 2026 06:36:57 +0800 Subject: [PATCH] Add Previous/Next navigation between detail views (#12397) Adds a generic NextPrevAction icon-button pair and a useNextPrevSiblings hook that resolves the adjacent instances via the existing REST API. * The component is intentionally model-agnostic and lives in the lib surface so plugins and other detail pages can reuse it. * PartDetail now renders the action next to the existing action group, scoped to the part list's active filter set. The same plumbing can be wired up to other detail pages in follow-up changes. * When the current instance has no sibling in a given direction, the corresponding button is disabled instead of hidden, so the affordance is consistent. * A Playwright regression test renders the buttons on the seeded part-detail page and verifies the click navigation behaviour. Refs https://github.com/inventree/InvenTree/issues/12397 --- .../lib/components/nav/NextPrevAction.tsx | 146 +++++++++++++ src/frontend/lib/index.ts | 5 + .../src/components/nav/PageDetail.tsx | 32 ++- .../src/hooks/UseNextPrevSiblings.tsx | 196 ++++++++++++++++++ src/frontend/src/pages/part/PartDetail.tsx | 53 ++++- src/frontend/tests/pui_part_next_prev.spec.ts | 75 +++++++ 6 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 src/frontend/lib/components/nav/NextPrevAction.tsx create mode 100644 src/frontend/src/hooks/UseNextPrevSiblings.tsx create mode 100644 src/frontend/tests/pui_part_next_prev.spec.ts diff --git a/src/frontend/lib/components/nav/NextPrevAction.tsx b/src/frontend/lib/components/nav/NextPrevAction.tsx new file mode 100644 index 000000000000..a6f900df845e --- /dev/null +++ b/src/frontend/lib/components/nav/NextPrevAction.tsx @@ -0,0 +1,146 @@ +import { ActionIcon, Group, Tooltip } from '@mantine/core'; +import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import type { ReactNode } from 'react'; + +import { t } from '@lingui/core/macro'; + +/** + * Minimal description of a single prev/next navigation target. + * + * The caller is responsible for resolving the actual destination URL / + * primary key. The component never decides what "next" means on its own, + * because the meaning depends on the model, filter set, and ordering + * policy of the calling list view. + */ +export interface NextPrevTarget { + /** Optional visible label for the destination (e.g. "Widget A") */ + label?: string; + /** Primary key of the destination instance */ + pk?: number | string | null; + /** + * Called when the user clicks the button. Should be a no-op if `pk` + * is undefined/null; the component already disables the button in + * that case. + */ + onClick?: () => void; + /** + * Set to true while the destination is being resolved. Disables the + * button and shows a muted tooltip. + */ + loading?: boolean; + /** + * Override the default tooltip text (English fallback). + * Localized messages should be supplied by the caller. + */ + tooltip?: string; +} + +export interface NextPrevActionProps { + /** Configuration for the "previous" button. Omit / leave undefined to hide it. */ + prev?: NextPrevTarget; + /** Configuration for the "next" button. Omit / leave undefined to hide it. */ + next?: NextPrevTarget; + /** + * Allow callers (e.g. detail pages) to provide localized tooltip + * messages keyed by 'prev' / 'next'. Falls back to the English default. + */ + labels?: { + prev?: ReactNode; + next?: ReactNode; + prevAria?: string; + nextAria?: string; + }; + /** Optional size override for both icons (default 'sm') */ + size?: string; +} + +/** + * Returns true when the target is usable (has either a pk or an onClick). + */ +function hasTarget(target?: NextPrevTarget): boolean { + if (!target) return false; + if (target.pk === undefined || target.pk === null) { + return Boolean(target.onClick); + } + return true; +} + +/** + * Render a "previous / next" navigation pair as compact action icons. + * + * This component is intentionally dumb: it does not know how to find the + * adjacent instance. The caller must supply `pk` (and/or `onClick`) for + * each side, typically by querying the API with the same filter / ordering + * parameters as the originating list. + * + * The component is exported through the `lib` surface so frontend plugins + * can reuse it for any entity detail view, matching the requirement set + * out in https://github.com/inventree/InvenTree/issues/12397. + */ +export function NextPrevAction({ + prev, + next, + labels, + size = 'sm' +}: Readonly) { + const showPrev = hasTarget(prev); + const showNext = hasTarget(next); + + if (!showPrev && !showNext) { + return null; + } + + const prevTooltip = labels?.prev ?? prev?.tooltip ?? t`Previous item`; + + const nextTooltip = labels?.next ?? next?.tooltip ?? t`Next item`; + + const prevDisabled = !prev?.onClick || prev.loading === true; + const nextDisabled = !next?.onClick || next.loading === true; + + return ( + + {showPrev && ( + + prev?.onClick?.()} + > + + + + )} + {showNext && ( + + next?.onClick?.()} + > + + + + )} + + ); +} diff --git a/src/frontend/lib/index.ts b/src/frontend/lib/index.ts index cb554301e0cb..2b456d5ef0df 100644 --- a/src/frontend/lib/index.ts +++ b/src/frontend/lib/index.ts @@ -147,6 +147,11 @@ export { DetailDrawerLink, DetailDrawerComponent } from './components/nav/DetailDrawer'; +export { + type NextPrevTarget, + type NextPrevActionProps, + NextPrevAction +} from './components/nav/NextPrevAction'; export { StylishText } from './components/StylishText'; // State management diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 915f37388d81..c39f37dbf109 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -1,6 +1,10 @@ import { Group, Paper, Space, Stack, Text } from '@mantine/core'; import { StylishText } from '@lib/components/StylishText'; +import { + NextPrevAction, + type NextPrevTarget +} from '@lib/components/nav/NextPrevAction'; import { useInvenTreeHotkeys } from '@lib/functions/Events'; import { shortenString } from '@lib/functions/String'; import { t } from '@lingui/core/macro'; @@ -15,6 +19,17 @@ import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes'; import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList'; import PageTitle from './PageTitle'; +export interface PageDetailNextPrev { + prev?: NextPrevTarget; + next?: NextPrevTarget; + labels?: { + prev?: ReactNode; + next?: ReactNode; + prevAria?: string; + nextAria?: string; + }; +} + interface PageDetailInterface { title?: string; icon?: ReactNode; @@ -28,6 +43,13 @@ interface PageDetailInterface { actions?: ReactNode[]; editAction?: () => void; editEnabled?: boolean; + /** + * Optional previous / next navigation affordances. When provided, two + * compact icon buttons are rendered next to the action group, allowing + * the user to step between sibling instances without returning to the + * list view. See https://github.com/inventree/InvenTree/issues/12397. + */ + nextPrev?: PageDetailNextPrev; } /** @@ -48,7 +70,8 @@ export function PageDetail({ breadcrumbAction, actions, editAction, - editEnabled + editEnabled, + nextPrev }: Readonly) { const userSettings = useUserSettingsState(); const navigate = useNavigate(); @@ -186,6 +209,13 @@ export function PageDetail({ {computedActions && ( + {nextPrev && ( + + )} {computedActions.map((action, idx) => ( {action} ))} diff --git a/src/frontend/src/hooks/UseNextPrevSiblings.tsx b/src/frontend/src/hooks/UseNextPrevSiblings.tsx new file mode 100644 index 000000000000..1aed711e70cf --- /dev/null +++ b/src/frontend/src/hooks/UseNextPrevSiblings.tsx @@ -0,0 +1,196 @@ +import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import type { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useApi } from '../contexts/ApiContext'; + +/** + * Configuration for {@link useNextPrevSiblings}. + * + * The hook treats the API as the source of truth. To find the previous + * instance it sends the same filter parameters plus `pk__lt=`, + * ordered descending, and asks for a single record. To find the next + * instance it sends `pk__gt=`, ordered ascending. + * + * Callers may override `pkField` if the underlying model uses a different + * primary key column. + */ +export interface UseNextPrevSiblingsOptions { + /** Model type whose detail view is rendering the buttons. */ + model: ModelType; + /** Primary key of the currently displayed instance. */ + pk?: number | string | null; + /** + * Additional API filter parameters to scope the previous / next + * lookup (e.g. `{ active: true, category: 5 }`). These should match + * the parameters used to render the originating list, so that "next" + * actually navigates within the visible set. + */ + filterParams?: Record; + /** Field used by the API for primary key comparison. Defaults to `'pk'`. */ + pkField?: string; + /** Optional explicit ordering field. Defaults to `pkField`. */ + ordering?: string; + /** Disable the lookup entirely (e.g. while current pk is still loading). */ + enabled?: boolean; +} + +/** + * Single neighbour resolved by the hook. `null` means there is no item + * in that direction within the supplied filter; `undefined` means the + * lookup is still in flight. + */ +export interface NextPrevSibling { + pk?: number | string; + /** Best-effort human label (pulled from common display fields). */ + label?: string; +} + +/** + * Resolve the previous / next sibling of a given instance, using the + * InvenTree REST API. + * + * The hook is conservative: it queries one record in each direction and + * surfaces both neighbours as soon as the data is available. The list of + * `params` is the API-filtered ordering context, not the API `ordering` + * query parameter (which defaults to the primary key column). + * + * This intentionally avoids client-side navigation state: a list view + * with hundreds of pages would otherwise have to load the entire page + * just to compute "what comes next". With API filtering we only ever + * ask for two records. + */ +export function useNextPrevSiblings({ + model, + pk, + filterParams, + pkField = 'pk', + ordering, + enabled = true +}: UseNextPrevSiblingsOptions): { + prev?: NextPrevSibling | null; + next?: NextPrevSibling | null; + isLoading: boolean; +} { + const api = useApi(); + const modelInfo = ModelInformationDict[model]; + + const orderField = ordering ?? pkField; + const numericPk = + pk === undefined || pk === null || pk === '' ? null : Number(pk); + + const baseParams = useMemo(() => { + const cleaned: Record = {}; + if (filterParams) { + for (const [key, value] of Object.entries(filterParams)) { + if (value === undefined || value === null) continue; + cleaned[key] = value; + } + } + return cleaned; + }, [filterParams]); + + const common = useMemo( + () => ({ + ...baseParams, + limit: 1, + ordering: orderField + }), + [baseParams, orderField] + ); + + const enabledQuery = + enabled && numericPk !== null && !!modelInfo?.api_endpoint; + + const prevQuery = useQuery({ + enabled: enabledQuery, + queryKey: [ + 'nextprev', + model, + pkField, + 'prev', + numericPk, + JSON.stringify(baseParams), + orderField + ], + queryFn: async () => { + const url = apiUrl(modelInfo!.api_endpoint); + const params = { + ...common, + [`${pkField}__lt`]: numericPk, + ordering: `-${orderField}` + }; + const response = await api.get(url, { params }); + const data = response?.data ?? []; + return data.length > 0 ? data[0] : null; + } + }); + + const nextQuery = useQuery({ + enabled: enabledQuery, + queryKey: [ + 'nextprev', + model, + pkField, + 'next', + numericPk, + JSON.stringify(baseParams), + orderField + ], + queryFn: async () => { + const url = apiUrl(modelInfo!.api_endpoint); + const params = { + ...common, + [`${pkField}__gt`]: numericPk, + ordering: orderField + }; + const response = await api.get(url, { params }); + const data = response?.data ?? []; + return data.length > 0 ? data[0] : null; + } + }); + + const pickLabel = useMemo(() => { + return (record: any): string | undefined => { + if (!record) return undefined; + return ( + record.full_name ?? + record.name ?? + record.description ?? + record.username ?? + record.IPN ?? + record.reference ?? + record.title + ); + }; + }, []); + + const prev = useMemo(() => { + if (!enabledQuery) return undefined; + if (prevQuery.isLoading) return undefined; + if (!prevQuery.data) return null; + const record = prevQuery.data; + const value = record[pkField]; + if (value === undefined || value === null) return null; + return { pk: value, label: pickLabel(record) }; + }, [enabledQuery, prevQuery.isLoading, prevQuery.data, pkField, pickLabel]); + + const next = useMemo(() => { + if (!enabledQuery) return undefined; + if (nextQuery.isLoading) return undefined; + if (!nextQuery.data) return null; + const record = nextQuery.data; + const value = record[pkField]; + if (value === null || value === undefined) return null; + return { pk: value, label: pickLabel(record) }; + }, [enabledQuery, nextQuery.isLoading, nextQuery.data, pkField, pickLabel]); + + return { + prev, + next, + isLoading: + (prevQuery.isLoading && prev === undefined) || + (nextQuery.isLoading && next === undefined) + }; +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 2bfcf93db94a..b1f97f987cd9 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -36,7 +36,7 @@ import { IconVersions } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import Select from 'react-select'; @@ -78,6 +78,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useNextPrevSiblings } from '../../hooks/UseNextPrevSiblings'; import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useGlobalSettingsState, @@ -182,6 +183,55 @@ export default function PartDetail() { refetchOnMount: true }); + // Previous / next siblings, scoped to the same active filter set as the + // default part list view. The active filter is intentionally conservative: + // any future improvement that captures the originating list's full filter + // context can pass it in here. + // See https://github.com/inventree/InvenTree/issues/12397 + const { prev: prevPart, next: nextPart } = useNextPrevSiblings({ + model: ModelType.part, + pk: part?.pk ?? id, + filterParams: { + active: part?.active, + assembly: part?.assembly + }, + enabled: !!part?.pk + }); + + const goToPart = useCallback( + (pk: number | string | undefined | null) => { + if (pk === undefined || pk === null) return; + navigate(getDetailUrl(ModelType.part, pk)); + }, + [navigate] + ); + + const partNextPrev = useMemo(() => { + if (!part?.pk) return undefined; + return { + prev: + prevPart === undefined + ? { loading: true } + : prevPart === null + ? undefined + : { + pk: prevPart.pk, + label: prevPart.label, + onClick: () => goToPart(prevPart.pk) + }, + next: + nextPart === undefined + ? { loading: true } + : nextPart === null + ? undefined + : { + pk: nextPart.pk, + label: nextPart.label, + onClick: () => goToPart(nextPart.pk) + } + }; + }, [part?.pk, prevPart, nextPart, goToPart]); + const { instance: partRequirements, instanceQuery: partRequirementsQuery } = useInstance({ endpoint: ApiEndpoints.part_requirements, @@ -862,6 +912,7 @@ export default function PartDetail() { editAction={editPart.open} editEnabled={user.hasChangeRole(UserRoles.part)} actions={partActions} + nextPrev={partNextPrev} /> { + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'part/1/details' + }); + + // The two icon buttons should be present and reachable by their + // stable test ids. + const prevButton = page.getByTestId('inventree-prev-item'); + const nextButton = page.getByTestId('inventree-next-item'); + + await prevButton.waitFor(); + await nextButton.waitFor(); + + // Capture the current URL so we can detect movement. + const startUrl = page.url(); + const startMatch = startUrl.match(/\/part\/(\d+)\//); + if (!startMatch) { + throw new Error(`Unexpected start URL: ${startUrl}`); + } + const startPk = Number(startMatch[1]); + + // At least one neighbour must exist for the seeded data set used by + // the Playwright fixtures; otherwise we can't exercise the click. + // Try clicking next; if it stays put, the dataset has only one part, + // and we still want the buttons to render without throwing. + await nextButton.click(); + + // Wait for either navigation or a no-op (button stays disabled). + await page.waitForLoadState('networkidle').catch(() => { + /* no-op for the disabled-button case */ + }); + + const urlAfterNext = page.url(); + const matchAfterNext = urlAfterNext.match(/\/part\/(\d+)\//); + if (matchAfterNext && Number(matchAfterNext[1]) !== startPk) { + // We did move; verify the prev button is now enabled (because + // there is at least the previous starting instance). + await prevButton.waitFor(); + await prevButton.click(); + await page.waitForLoadState('networkidle').catch(() => { + /* see above */ + }); + // After clicking prev we should be back on the starting part (or + // even earlier). At minimum we must still be on a /part// URL. + const urlAfterPrev = page.url(); + expect(urlAfterPrev).toMatch(/\/part\/\d+\//); + } else { + // No next sibling - the dataset has a single part. The next button + // should be disabled to make the affordance honest. + await expect(nextButton).toBeDisabled(); + } + + // The previous button is rendered too; it should be disabled when + // there is no previous sibling and enabled otherwise. We don't make + // a hard assertion here because the dev fixture set can vary. + await prevButton.waitFor(); +});