diff --git a/docs/docs/concepts/ui/index.md b/docs/docs/concepts/ui/index.md index 7410220a53..758ee4a904 100644 --- a/docs/docs/concepts/ui/index.md +++ b/docs/docs/concepts/ui/index.md @@ -24,8 +24,8 @@ From the main menu, users can access the following items: - [Dashboard](#dashboard) - [Global Search](./global_search.md) - [Spotlight](#spotlight) -- [Barcode Scanning](#barcode-scanning) -- [Notifications](#notifications) +- [Barcode Scanning](../../barcodes/index.md#quick-scan) +- [Notifications](../../part/notification.md) - [User Menu](#user-menu) As well as allowing navigation to the following main sections: @@ -84,6 +84,14 @@ On some pages, a breadcrumb navigation trail is provided at the top of the page, {{ image("concepts/ui_breadcrumbs.png", "Breadcrumb Navigation") }} +#### Filtered Detail Navigation + +When a detail page is opened from a table that supports detail navigation, the breadcrumb bar can show the current position in the table result set together with Previous and Next buttons. The navigation uses the table's active filters and sort order, so moving between records follows the same list the user was viewing. + +The navigation context is stored in the detail URL. This makes links shareable and allows the same filtered result set to be restored when the link is opened in a fresh browser context. The position indicator uses the full result count, while the buttons are hidden at the first and last records. + +The navigation controls also work with detail pages provided by frontend plugins that use the standard table and page components. + ### Navigation Tree On some pages, a navigation tree is provided on the left-hand side of the page, next to the breadcrumbs. The navigation tree provides a hierarchical view of the current section of the system, allowing users to quickly navigate to related pages and sections. diff --git a/src/frontend/src/components/nav/BreadcrumbList.tsx b/src/frontend/src/components/nav/BreadcrumbList.tsx index 3dadf45171..e7d649f046 100644 --- a/src/frontend/src/components/nav/BreadcrumbList.tsx +++ b/src/frontend/src/components/nav/BreadcrumbList.tsx @@ -1,11 +1,4 @@ -import { - ActionIcon, - Anchor, - Breadcrumbs, - Group, - Paper, - Text -} from '@mantine/core'; +import { ActionIcon, Anchor, Breadcrumbs, Group, Text } from '@mantine/core'; import { IconMenu2 } from '@tabler/icons-react'; import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -47,41 +40,44 @@ export function BreadcrumbList({ }, [breadcrumbs]); return ( - - - {navCallback && ( - - - - )} - - {elements.map((breadcrumb, index) => { - return ( - - breadcrumb.url && - navigateToLink(breadcrumb.url, navigate, event) - } - > - - {breadcrumb.icon} - {breadcrumb.name} - - - ); - })} - - - + + {navCallback && ( + + + + )} + + {elements.map((breadcrumb, index) => { + return ( + + breadcrumb.url && + navigateToLink(breadcrumb.url, navigate, event) + } + > + + {breadcrumb.icon} + {breadcrumb.name} + + + ); + })} + + ); } diff --git a/src/frontend/src/components/nav/DetailNavigation.tsx b/src/frontend/src/components/nav/DetailNavigation.tsx new file mode 100644 index 0000000000..cd4abdb052 --- /dev/null +++ b/src/frontend/src/components/nav/DetailNavigation.tsx @@ -0,0 +1,68 @@ +import { ActionIcon, Group, Text, Tooltip } from '@mantine/core'; +import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; + +import { t } from '@lingui/core/macro'; +import type { DetailNavigationState } from '../../hooks/UseDetailNavigation'; + +export function DetailNavigation({ + navigation +}: Readonly<{ navigation: DetailNavigationState }>) { + const hasNavigation = Boolean( + navigation.previous || + navigation.next || + navigation.position || + navigation.isLoading + ); + + if (!hasNavigation) { + return null; + } + + return ( + + {navigation.position && ( + + {t`${navigation.position.current} of ${navigation.position.total}`} + + )} + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 915f37388d..cc21b487d3 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -6,13 +6,15 @@ 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 { useDetailNavigation } from '../../hooks/UseDetailNavigation'; 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 type { Breadcrumb } from './BreadcrumbList'; +import { PageDetailNavigationBar } from './PageDetailNavigationBar'; import PageTitle from './PageTitle'; interface PageDetailInterface { @@ -53,6 +55,7 @@ export function PageDetail({ const userSettings = useUserSettingsState(); const navigate = useNavigate(); const location = useLocation(); + const detailNavigation = useDetailNavigation(); useInvenTreeHotkeys([ [ @@ -134,12 +137,11 @@ export function PageDetail({ <> - {computedBreadcrumbs && computedBreadcrumbs.length > 0 && ( - - )} + )} - {computedActions && ( - + {computedActions.length > 0 && ( + {computedActions.map((action, idx) => ( {action} ))} diff --git a/src/frontend/src/components/nav/PageDetailNavigationBar.tsx b/src/frontend/src/components/nav/PageDetailNavigationBar.tsx new file mode 100644 index 0000000000..d9cff46ed6 --- /dev/null +++ b/src/frontend/src/components/nav/PageDetailNavigationBar.tsx @@ -0,0 +1,40 @@ +import { Group, Paper } from '@mantine/core'; + +import type { DetailNavigationState } from '../../hooks/UseDetailNavigation'; +import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList'; +import { DetailNavigation } from './DetailNavigation'; + +export function PageDetailNavigationBar({ + breadcrumbs, + breadcrumbAction, + detailNavigation +}: Readonly<{ + breadcrumbs: Breadcrumb[]; + breadcrumbAction?: () => void; + detailNavigation: DetailNavigationState; +}>) { + const hasDetailNavigation = Boolean( + detailNavigation.previous || + detailNavigation.next || + detailNavigation.position || + detailNavigation.isLoading + ); + + if (breadcrumbs.length === 0 && !hasDetailNavigation) { + return null; + } + + return ( + + + + {hasDetailNavigation && ( + + )} + + + ); +} diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index 6dc549270d..e8efb4a91b 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -103,6 +103,7 @@ function PanelTabComponent({ onClick: (event: any) => void; }) { const visibility = useDocumentVisibility(); + const location = useLocation(); // Check if we should display an indicator dot for this panel const notificationDot = useQuery({ @@ -172,7 +173,9 @@ function PanelTabComponent({ textAlign: 'left' }} href={generateUrl( - `/${getBaseUrl()}${location.pathname}/${panel.name}` + `/${getBaseUrl()}${location.pathname}/${panel.name}${ + location.search + }` )} > {expanded && panel.label} @@ -304,10 +307,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 +511,7 @@ function IndexPanelComponent({ defaultPanel, panels }: Readonly) { + const location = useLocation(); const lastUsedPanel = useLocalState( useShallow((state) => { const panelName = @@ -527,7 +531,7 @@ function IndexPanelComponent({ }) ); - return ; + return ; } /** diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 15ef3cde1d..48039899a4 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -34,6 +34,12 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useApi } from '../../contexts/ApiContext'; +import { + buildDetailNavigationUrl, + excludeDetailNavigationParams, + extractDetailNavigationParams, + isSafeApiListUrl +} from '../../functions/DetailNavigation'; import { extractAvailableFields } from '../../functions/forms'; import { showApiErrorMessage } from '../../functions/notifications'; import { useLocalState } from '../../states/LocalState'; @@ -114,6 +120,20 @@ export function InvenTreeTableInternal>({ return userSettings.isSet('STICKY_TABLE_HEADER'); }, [userSettings]); + const tableSearchParams = useMemo(() => { + return searchParams + ? excludeDetailNavigationParams(searchParams) + : undefined; + }, [searchParams]); + + const clearTableSearchParams = useCallback(() => { + setSearchParams?.( + searchParams + ? extractDetailNavigationParams(searchParams) + : new URLSearchParams() + ); + }, [searchParams, setSearchParams]); + // Key used for caching table data const cacheKey = useMemo(() => { const key: string = `tbl-${tableState.tableKey}`; @@ -455,7 +475,11 @@ export function InvenTreeTableInternal>({ useEffect(() => { tableState.setPage(1); tableState.clearSelectedRecords(); - }, [tableState.searchTerm, tableState.filterSet.activeFilters, searchParams]); + }, [ + tableState.searchTerm, + tableState.filterSet.activeFilters, + tableSearchParams + ]); // Account for invalid page offsets useEffect(() => { @@ -489,9 +513,9 @@ export function InvenTreeTableInternal>({ ...tableProps.params }; - if (searchParams && searchParams.size > 0) { + if (tableSearchParams && tableSearchParams.size > 0) { // Allow override of filters based on URL query parameters - for (const [key, value] of searchParams) { + for (const [key, value] of tableSearchParams) { queryParams[key] = value; } } else if (tableState.filterSet.activeFilters) { @@ -530,12 +554,55 @@ export function InvenTreeTableInternal>({ tableProps.params, tableProps.enablePagination, tableState.filterSet.activeFilters, - searchParams, + tableSearchParams, tableState.searchTerm, getOrderingTerm ] ); + const getDetailNavigationUrl = useCallback( + (record: any, index: number): string | undefined => { + if ( + !tableProps.modelType || + !url || + !isSafeApiListUrl(url) || + index < 0 + ) { + return undefined; + } + + const accessor = tableProps.modelField ?? 'pk'; + const pk = resolveItem(record, accessor); + + if (pk === null || pk === undefined || pk === '') { + return undefined; + } + + const absoluteIndex = + tableProps.enablePagination === false + ? index + : (tableState.page - 1) * pageSize + index; + + return buildDetailNavigationUrl({ + detailUrl: getDetailUrl(tableProps.modelType, pk), + apiUrl: url, + queryParams: getTableFilters(false), + index: absoluteIndex, + pk, + field: accessor + }); + }, + [ + getTableFilters, + pageSize, + tableProps.enablePagination, + tableProps.modelField, + tableProps.modelType, + tableState.page, + url + ] + ); + const [cacheLoaded, setCacheLoaded] = useState(false); useEffect(() => { @@ -672,7 +739,7 @@ export function InvenTreeTableInternal>({ // Refetch data when the query parameters change useEffect(() => { refetch(); - }, [searchParams]); + }, [tableSearchParams]); useEffect(() => { const loading: boolean = @@ -709,9 +776,16 @@ export function InvenTreeTableInternal>({ // Callback to display "preview" view for a row (if available) const showRowPreview = useCallback( - (pk: string | number) => { + (pk: string | number, targetUrl?: string) => { if (tableProps.modelType && pk) { - previewDrawer.openPreview(tableProps.modelType, Number(pk)); + previewDrawer.openPreview( + tableProps.modelType, + Number(pk), + undefined, + undefined, + undefined, + targetUrl + ); } }, [tableProps.modelType] @@ -748,17 +822,26 @@ 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 = + getDetailNavigationUrl(record, index) ?? + getDetailUrl(tableProps.modelType, pk); if (!showPreviewPanel || eventModified(event as any)) { - navigateToLink(url, navigate, event); + navigateToLink(detailUrl, navigate, event); } else { - showRowPreview(pk); + showRowPreview(pk, detailUrl); } } } }, - [props.onRowClick, props.onCellClick, showPreviewPanel] + [ + getDetailNavigationUrl, + props.onCellClick, + props.onRowClick, + showPreviewPanel, + showRowPreview, + tableProps.modelType + ] ); const supportsContextMenu = useMemo(() => { @@ -802,7 +885,12 @@ 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 recordIndex = tableState.records.findIndex( + (item) => String(resolveItem(item, accessor)) === String(pk) + ); + const detailUrl = + getDetailNavigationUrl(record, recordIndex) ?? + getDetailUrl(props.modelType, pk); const model: string | undefined = ModelInformationDict[props.modelType]?.label?.(); @@ -820,9 +908,9 @@ 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); + showRowPreview(pk, detailUrl); } } }); @@ -836,10 +924,12 @@ export function InvenTreeTableInternal>({ props.modelType, props.detailAction, props.modelField, + getDetailNavigationUrl, showPreviewPanel, showRowPreview, showContextMenu, - navigate + navigate, + tableState.records ] ); @@ -930,8 +1020,8 @@ export function InvenTreeTableInternal>({ hasSwitchableColumns={hasSwitchableColumns} columns={dataColumns} filters={filters} - queryFilters={searchParams} - clearQueryFilters={() => setSearchParams?.(new URLSearchParams())} + queryFilters={tableSearchParams} + clearQueryFilters={clearTableSearchParams} toggleColumn={toggleColumn} /> diff --git a/src/frontend/src/functions/DetailNavigation.tsx b/src/frontend/src/functions/DetailNavigation.tsx new file mode 100644 index 0000000000..4649b05cfa --- /dev/null +++ b/src/frontend/src/functions/DetailNavigation.tsx @@ -0,0 +1,298 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import type { MouseEvent } from 'react'; + +export const DETAIL_NAVIGATION_PARAMS = { + api: '_na', + query: '_nq', + index: '_ni', + pk: '_np', + field: '_nf' +} as const; + +const DETAIL_NAVIGATION_PARAM_KEYS = new Set( + Object.values(DETAIL_NAVIGATION_PARAMS) +); +const PAGINATION_PARAMS = new Set(['limit', 'offset', 'page']); +const DEFAULT_DETAIL_NAVIGATION_FIELD = 'pk'; + +// Keep common built-in list endpoints compact while preserving the full URL +// for plugin and otherwise unknown endpoints. +const DETAIL_NAVIGATION_API_ALIASES = new Map([ + [apiUrl(ApiEndpoints.part_list), 'p'], + [apiUrl(ApiEndpoints.category_list), 'pc'], + [apiUrl(ApiEndpoints.stock_item_list), 's'], + [apiUrl(ApiEndpoints.stock_location_list), 'sl'], + [apiUrl(ApiEndpoints.build_order_list), 'b'], + [apiUrl(ApiEndpoints.build_line_list), 'bl'], + [apiUrl(ApiEndpoints.build_item_list), 'bi'], + [apiUrl(ApiEndpoints.company_list), 'c'], + [apiUrl(ApiEndpoints.supplier_part_list), 'sp'], + [apiUrl(ApiEndpoints.manufacturer_part_list), 'mp'], + [apiUrl(ApiEndpoints.purchase_order_list), 'po'], + [apiUrl(ApiEndpoints.purchase_order_line_list), 'pol'], + [apiUrl(ApiEndpoints.sales_order_list), 'so'], + [apiUrl(ApiEndpoints.sales_order_line_list), 'sol'], + [apiUrl(ApiEndpoints.sales_order_shipment_list), 'sh'], + [apiUrl(ApiEndpoints.return_order_list), 'ro'], + [apiUrl(ApiEndpoints.return_order_line_list), 'rol'], + [apiUrl(ApiEndpoints.transfer_order_list), 'to'], + [apiUrl(ApiEndpoints.transfer_order_line_list), 'tol'], + [apiUrl(ApiEndpoints.transfer_order_allocation_list), 'toa'], + [apiUrl(ApiEndpoints.sales_order_allocation_list), 'soa'], + [apiUrl(ApiEndpoints.bom_list), 'bom'], + [apiUrl(ApiEndpoints.parameter_list), 'pa'], + [apiUrl(ApiEndpoints.parameter_template_list), 'pt'], + [apiUrl(ApiEndpoints.user_list), 'u'], + [apiUrl(ApiEndpoints.group_list), 'ug'], + [apiUrl(ApiEndpoints.project_code_list), 'pr'], + [apiUrl(ApiEndpoints.tag_list), 'tag'], + [apiUrl(ApiEndpoints.attachment_list), 'at'], + [apiUrl(ApiEndpoints.machine_list), 'm'] +]); + +const DETAIL_NAVIGATION_API_URLS = new Map( + Array.from(DETAIL_NAVIGATION_API_ALIASES.entries()).map(([url, alias]) => [ + alias, + url + ]) +); + +export type DetailNavigationContext = { + apiUrl: string; + query: URLSearchParams; + index: number; + pk: string; + field: string; +}; + +export type DetailNavigationAction = { + href: string; + onClick: (event: MouseEvent) => void; +}; + +type DetailNavigationParam = keyof typeof DETAIL_NAVIGATION_PARAMS; + +function getDetailNavigationParam( + params: URLSearchParams, + key: DetailNavigationParam +): string | null { + return params.get(DETAIL_NAVIGATION_PARAMS[key]); +} + +function encodeDetailNavigationApi(apiUrl: string): string { + return DETAIL_NAVIGATION_API_ALIASES.get(apiUrl) ?? apiUrl; +} + +function decodeDetailNavigationApi(apiUrl: string): string { + return DETAIL_NAVIGATION_API_URLS.get(apiUrl) ?? apiUrl; +} + +function removeDetailNavigationParams(params: URLSearchParams) { + DETAIL_NAVIGATION_PARAM_KEYS.forEach((key) => { + params.delete(key); + }); +} + +function filterDetailNavigationParams( + params: URLSearchParams, + includeDetailNavigationParams: boolean +): URLSearchParams { + const filteredParams = new URLSearchParams(); + + for (const [key, value] of params) { + if ( + DETAIL_NAVIGATION_PARAM_KEYS.has(key) === includeDetailNavigationParams + ) { + filteredParams.append(key, value); + } + } + + return filteredParams; +} + +export function excludeDetailNavigationParams( + params: URLSearchParams +): URLSearchParams { + return filterDetailNavigationParams(params, false); +} + +export function extractDetailNavigationParams( + params: URLSearchParams +): URLSearchParams { + return filterDetailNavigationParams(params, true); +} + +function setDetailNavigationParams( + target: URL, + { + apiUrl, + query, + index, + pk, + field + }: { + apiUrl: string; + query: string; + index: number; + pk: string | number; + field: string; + } +) { + removeDetailNavigationParams(target.searchParams); + + target.searchParams.set( + DETAIL_NAVIGATION_PARAMS.api, + encodeDetailNavigationApi(apiUrl) + ); + target.searchParams.set(DETAIL_NAVIGATION_PARAMS.index, String(index)); + target.searchParams.set(DETAIL_NAVIGATION_PARAMS.pk, String(pk)); + + if (field !== DEFAULT_DETAIL_NAVIGATION_FIELD) { + target.searchParams.set(DETAIL_NAVIGATION_PARAMS.field, field); + } + + if (query) { + target.searchParams.set(DETAIL_NAVIGATION_PARAMS.query, query); + } +} + +export function isSafeApiListUrl(url?: string): url is string { + return !!url && url.startsWith('/api/') && !url.startsWith('//'); +} + +function appendQueryValue(query: URLSearchParams, key: string, value: unknown) { + if (value === null || value === undefined || PAGINATION_PARAMS.has(key)) { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => appendQueryValue(query, key, item)); + return; + } + + if (typeof value === 'object') { + query.append(key, JSON.stringify(value)); + return; + } + + query.append(key, value.toString()); +} + +export function serializeDetailNavigationQuery( + params: Record +): string { + const query = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + appendQueryValue(query, key, value); + }); + + return query.toString(); +} + +export function buildDetailNavigationUrl({ + detailUrl, + apiUrl, + queryParams, + index, + pk, + field +}: { + detailUrl: string; + apiUrl: string; + queryParams: Record; + index: number; + pk: string | number; + field: string; +}): string { + const target = new URL(detailUrl, 'https://inventree.local'); + const query = serializeDetailNavigationQuery(queryParams); + + setDetailNavigationParams(target, { + apiUrl, + query, + index, + pk, + field + }); + + return `${target.pathname}${target.search}${target.hash}`; +} + +export function readDetailNavigationContext( + search: string +): DetailNavigationContext | null { + const params = new URLSearchParams(search); + const encodedApiUrl = getDetailNavigationParam(params, 'api'); + const apiUrl = encodedApiUrl + ? decodeDetailNavigationApi(encodedApiUrl) + : null; + const query = getDetailNavigationParam(params, 'query') ?? ''; + const index = Number(getDetailNavigationParam(params, 'index')); + const pk = getDetailNavigationParam(params, 'pk'); + const field = + getDetailNavigationParam(params, 'field') ?? + DEFAULT_DETAIL_NAVIGATION_FIELD; + + if ( + !apiUrl || + !isSafeApiListUrl(apiUrl) || + !Number.isInteger(index) || + index < 0 || + pk === null + ) { + return null; + } + + return { + apiUrl, + query: new URLSearchParams(query), + index, + pk, + field + }; +} + +export function replaceDetailPk( + pathname: string, + currentPk: string, + targetPk: string | number +): string { + const segments = pathname.split('/'); + const currentValue = String(currentPk); + const targetValue = encodeURIComponent(String(targetPk)); + let replaced = false; + + const nextSegments = segments.map((segment) => { + if (!replaced && decodeURIComponent(segment) === currentValue) { + replaced = true; + return targetValue; + } + + return segment; + }); + + return replaced ? nextSegments.join('/') : pathname; +} + +export function buildDetailNavigationTarget( + pathname: string, + search: string, + context: DetailNavigationContext, + targetPk: string | number, + targetIndex: number +): string { + const nextPath = replaceDetailPk(pathname, context.pk, targetPk); + const target = new URL(`${nextPath}${search}`, 'https://inventree.local'); + + setDetailNavigationParams(target, { + apiUrl: context.apiUrl, + query: context.query.toString(), + index: targetIndex, + pk: targetPk, + field: context.field + }); + + return `${target.pathname}${target.search}${target.hash}`; +} diff --git a/src/frontend/src/hooks/UseDetailNavigation.tsx b/src/frontend/src/hooks/UseDetailNavigation.tsx new file mode 100644 index 0000000000..e5105b5164 --- /dev/null +++ b/src/frontend/src/hooks/UseDetailNavigation.tsx @@ -0,0 +1,135 @@ +import { resolveItem } from '@lib/functions/Conversion'; +import { navigateToLink } from '@lib/functions/Navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useApi } from '../contexts/ApiContext'; +import { + type DetailNavigationAction, + buildDetailNavigationTarget, + readDetailNavigationContext +} from '../functions/DetailNavigation'; + +type DetailNavigationData = { + records: any[]; + total: number; +}; + +export type DetailNavigationState = { + previous?: DetailNavigationAction; + next?: DetailNavigationAction; + position?: { + current: number; + total: number; + }; + isLoading: boolean; +}; + +export function useDetailNavigation(): DetailNavigationState { + const api = useApi(); + const location = useLocation(); + const navigate = useNavigate(); + + const context = useMemo( + () => readDetailNavigationContext(location.search), + [location.search] + ); + + const listQuery = useQuery({ + enabled: context !== null, + queryKey: [ + 'detail-navigation', + context?.apiUrl, + context?.query.toString(), + context?.index, + context?.pk, + context?.field + ], + queryFn: async (): Promise => { + if (!context) { + return { records: [], total: 0 }; + } + + const params = new URLSearchParams(context.query); + const offset = Math.max(context.index - 1, 0); + + params.set('limit', '3'); + params.set('offset', String(offset)); + + const response = await api.get(context.apiUrl, { + params, + timeout: 10000 + }); + + const rawRecords = response.data?.results ?? response.data ?? []; + const records = Array.isArray(rawRecords) ? rawRecords : []; + const count = Number(response.data?.count); + + return { + records, + total: Number.isFinite(count) && count >= 0 ? count : records.length + }; + } + }); + + const records = listQuery.data?.records ?? []; + const total = listQuery.data?.total ?? 0; + const currentRecordIndex = context + ? records.findIndex( + (record: any) => + String(resolveItem(record, context.field)) === context.pk + ) + : -1; + const listOffset = context ? Math.max(context.index - 1, 0) : 0; + + const createAction = ( + record: any, + relativeIndex: number + ): DetailNavigationAction | undefined => { + if (!context) { + return undefined; + } + + const targetPk = resolveItem(record, context.field); + if (targetPk === null || targetPk === undefined) { + return undefined; + } + + const targetIndex = listOffset + relativeIndex; + const href = buildDetailNavigationTarget( + location.pathname, + location.search, + context, + targetPk, + targetIndex + ); + + return { + href, + onClick: (event) => navigateToLink(href, navigate, event) + }; + }; + + if (!context || currentRecordIndex < 0) { + return { isLoading: listQuery.isLoading }; + } + + return { + previous: + currentRecordIndex > 0 + ? createAction(records[currentRecordIndex - 1], currentRecordIndex - 1) + : undefined, + next: + currentRecordIndex < records.length - 1 + ? createAction(records[currentRecordIndex + 1], currentRecordIndex + 1) + : undefined, + position: + total > context.index + ? { + current: context.index + 1, + total + } + : undefined, + isLoading: listQuery.isLoading + }; +} diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 3d39c8fc8c..6912e8a90a 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -498,7 +498,7 @@ test('Stock - Return Items', async ({ browser }) => { await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0'); await page.getByRole('button', { name: 'Submit' }).click(); - await page.getByText('Errors exist for one or more form fields').waitFor(); + await page.getByRole('alert', { name: 'Form Error' }).waitFor(); await page.getByText('Quantity must be greater than zero').first().waitFor(); await page.getByText('This field is required.').first().waitFor(); }); diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index 37e7197d08..b5ba128893 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import { test } from './baseFixtures.js'; import { stevenuser } from './defaults.js'; import { @@ -136,6 +137,160 @@ test('Tables - Pagination', async ({ browser }) => { await page.getByText(/1 - 25 \/ \d+/).waitFor(); }); +test('Tables - Navigation query parameters', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'part/category/index/parts/' + }); + + await clearTableFilters(page); + + const navigationParams = new URLSearchParams({ + _na: 'p', + _nq: 'search=530470', + _ni: '0', + _np: '69', + _nf: 'pk' + }); + + const waitForPartList = () => + page.waitForResponse((response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'GET' && + url.pathname === '/api/part/' && + url.searchParams.has('limit') + ); + }); + + const navigationRequest = waitForPartList(); + await navigate( + page, + `part/category/index/parts/?${navigationParams.toString()}` + ); + + const navigationRequestUrl = new URL((await navigationRequest).url()); + + for (const key of navigationParams.keys()) { + expect(navigationRequestUrl.searchParams.has(key)).toBe(false); + } + + await expect(page.getByText('Custom table filters are active')).toHaveCount( + 0 + ); + await expect(page.getByLabel('table-select-filters')).toBeEnabled(); + + await setTableChoiceFilter(page, 'Assembly', 'Yes'); + + for (const [key, value] of navigationParams) { + expect(new URL(page.url()).searchParams.get(key)).toBe(value); + } + + await clearTableFilters(page); + + for (const [key, value] of navigationParams) { + expect(new URL(page.url()).searchParams.get(key)).toBe(value); + } + + const searchParams = new URLSearchParams(navigationParams); + searchParams.set('search', '530470'); + + const searchRequest = waitForPartList(); + await navigate(page, `part/category/index/parts/?${searchParams.toString()}`); + + const searchRequestUrl = new URL((await searchRequest).url()); + expect(searchRequestUrl.searchParams.get('search')).toBe('530470'); + + for (const key of navigationParams.keys()) { + expect(searchRequestUrl.searchParams.has(key)).toBe(false); + } + + await expect(page.getByRole('cell', { name: '530470210' })).toBeVisible(); + + const queryFilterAlert = page + .getByRole('alert') + .filter({ hasText: 'Custom table filters are active' }); + await expect(queryFilterAlert).toBeVisible(); + await expect(page.getByLabel('table-select-filters')).toBeDisabled(); + + await queryFilterAlert.getByRole('button').click(); + await expect(queryFilterAlert).toBeHidden(); + await expect + .poll(() => new URL(page.url()).searchParams.has('search')) + .toBe(false); + + for (const [key, value] of navigationParams) { + expect(new URL(page.url()).searchParams.get(key)).toBe(value); + } + + await expect(page.getByLabel('table-select-filters')).toBeEnabled(); +}); + +test('Tables - Detail navigation', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'part/category/index/parts/?search=530470' + }); + + const firstRow = page.locator('tbody tr').first(); + await firstRow.waitFor(); + await firstRow.locator('td').nth(1).click(); + await page.waitForURL(/\/web\/part\/\d+(?:\/.*)?$/); + + const position = page.getByText(/^\d+ of \d+$/).first(); + await expect(position).toHaveText('1 of 2'); + + const breadcrumbBar = page.getByTestId('breadcrumb-list'); + const detailNavigation = breadcrumbBar.getByTestId('detail-navigation'); + await expect(detailNavigation).toBeVisible(); + + const previous = page.getByLabel('Previous', { exact: true }); + await expect(previous).toBeVisible(); + await expect(previous.locator('svg')).toBeVisible(); + await expect(previous).toHaveAttribute('data-disabled', 'true'); + await expect(previous).not.toHaveAttribute('href'); + + const next = page.getByLabel('Next', { exact: true }); + await expect(next).toBeVisible(); + await expect(next.locator('svg')).toBeVisible(); + await expect(next).not.toHaveAttribute('data-disabled'); + + const nextHref = await next.getAttribute('href'); + expect(nextHref).toContain('_na=p'); + expect(nextHref).toContain('_ni=1'); + expect(nextHref).toContain('_np='); + expect(nextHref).not.toContain('_nav_'); + expect(nextHref).not.toContain('_nf='); + + const initialBreadcrumbHeight = (await breadcrumbBar.boundingBox())?.height; + + await next.click(); + await expect(position).toHaveText('2 of 2'); + + const nextBreadcrumbHeight = (await breadcrumbBar.boundingBox())?.height; + expect(initialBreadcrumbHeight).toBeDefined(); + expect(nextBreadcrumbHeight).toBeDefined(); + expect( + Math.abs((nextBreadcrumbHeight ?? 0) - (initialBreadcrumbHeight ?? 0)) + ).toBeLessThanOrEqual(1); + + await expect(previous).toBeVisible(); + await expect(previous.locator('svg')).toBeVisible(); + await expect(previous).not.toHaveAttribute('data-disabled'); + await expect(previous).toHaveAttribute('href'); + + await expect(next).toBeVisible(); + await expect(next.locator('svg')).toBeVisible(); + await expect(next).toHaveAttribute('data-disabled', 'true'); + await expect(next).not.toHaveAttribute('href'); + + await previous.click(); + await expect(position).toHaveText('1 of 2'); + await expect(previous).toHaveAttribute('data-disabled', 'true'); + await expect(previous).not.toHaveAttribute('href'); + await expect(next).not.toHaveAttribute('data-disabled'); + await expect(next).toHaveAttribute('href'); +}); + test('Tables - Columns', async ({ browser }) => { // Go to the "stock list" page const page = await doCachedLogin(browser, {