mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-20 20:19:44 +00:00
feat(frontend): Add persistant filtered detail navigation in the url (#12585)
* Add filtered detail navigation * Satisfy navigation lint checks * Fix missing Fragment import in PageDetail * Address detail navigation review feedback * Move detail navigation into breadcrumb bar * Address URL and layout review feedback * Address detail navigation review feedback * fix doclinks * test: update stock return validation assertion * fix: keep detail navigation actions stable * style: apply biome formatting * fix: use ActionIcon disabled state * test: use ActionIcon disabled state * fix: isolate detail navigation query params --------- Co-authored-by: Matthias Mair <code@mjmair.com> Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
co-authored by
Matthias Mair
Oliver
parent
8f1bb69eba
commit
16ca6c245f
@@ -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 (
|
||||
<Paper p='7' radius='xs' shadow='xs'>
|
||||
<Group gap='xs'>
|
||||
{navCallback && (
|
||||
<ActionIcon
|
||||
key='nav-breadcrumb-action'
|
||||
aria-label='nav-breadcrumb-action'
|
||||
onClick={navCallback}
|
||||
variant='transparent'
|
||||
>
|
||||
<IconMenu2 />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Breadcrumbs key='breadcrumbs' separator='>'>
|
||||
{elements.map((breadcrumb, index) => {
|
||||
return (
|
||||
<Anchor
|
||||
key={`${index}-${breadcrumb.name}`}
|
||||
href={`/${baseUrl}${breadcrumb.url}`}
|
||||
aria-label={`breadcrumb-${index}-${identifierString(
|
||||
breadcrumb.name
|
||||
)}`}
|
||||
onClick={(event: any) =>
|
||||
breadcrumb.url &&
|
||||
navigateToLink(breadcrumb.url, navigate, event)
|
||||
}
|
||||
>
|
||||
<Group gap={4}>
|
||||
{breadcrumb.icon}
|
||||
<Text size='sm'>{breadcrumb.name}</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group
|
||||
gap='xs'
|
||||
wrap='nowrap'
|
||||
align='center'
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{navCallback && (
|
||||
<ActionIcon
|
||||
key='nav-breadcrumb-action'
|
||||
aria-label='nav-breadcrumb-action'
|
||||
onClick={navCallback}
|
||||
variant='transparent'
|
||||
>
|
||||
<IconMenu2 />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Breadcrumbs key='breadcrumbs' separator='>'>
|
||||
{elements.map((breadcrumb, index) => {
|
||||
return (
|
||||
<Anchor
|
||||
key={`${index}-${breadcrumb.name}`}
|
||||
href={`/${baseUrl}${breadcrumb.url}`}
|
||||
aria-label={`breadcrumb-${index}-${identifierString(
|
||||
breadcrumb.name
|
||||
)}`}
|
||||
onClick={(event: any) =>
|
||||
breadcrumb.url &&
|
||||
navigateToLink(breadcrumb.url, navigate, event)
|
||||
}
|
||||
>
|
||||
<Group gap={4} align='center'>
|
||||
{breadcrumb.icon}
|
||||
<Text size='sm'>{breadcrumb.name}</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Group
|
||||
gap={5}
|
||||
justify='right'
|
||||
wrap='nowrap'
|
||||
align='center'
|
||||
data-testid='detail-navigation'
|
||||
style={{ flexShrink: 0, minHeight: 36 }}
|
||||
>
|
||||
{navigation.position && (
|
||||
<Text
|
||||
size='xs'
|
||||
c='dimmed'
|
||||
px={4}
|
||||
aria-label='detail-navigation-position'
|
||||
>
|
||||
{t`${navigation.position.current} of ${navigation.position.total}`}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip label={t`Previous`} position='top'>
|
||||
<ActionIcon
|
||||
component='a'
|
||||
href={navigation.previous?.href}
|
||||
onClick={navigation.previous?.onClick}
|
||||
disabled={!navigation.previous}
|
||||
size='md'
|
||||
variant='subtle'
|
||||
aria-label={t`Previous`}
|
||||
>
|
||||
<IconChevronLeft size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t`Next`} position='top'>
|
||||
<ActionIcon
|
||||
component='a'
|
||||
href={navigation.next?.href}
|
||||
onClick={navigation.next?.onClick}
|
||||
disabled={!navigation.next}
|
||||
size='md'
|
||||
variant='subtle'
|
||||
aria-label={t`Next`}
|
||||
>
|
||||
<IconChevronRight size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<>
|
||||
<PageTitle title={pageTitleString} />
|
||||
<Stack gap='xs'>
|
||||
{computedBreadcrumbs && computedBreadcrumbs.length > 0 && (
|
||||
<BreadcrumbList
|
||||
navCallback={breadcrumbAction}
|
||||
breadcrumbs={computedBreadcrumbs}
|
||||
/>
|
||||
)}
|
||||
<PageDetailNavigationBar
|
||||
breadcrumbAction={breadcrumbAction}
|
||||
breadcrumbs={computedBreadcrumbs ?? []}
|
||||
detailNavigation={detailNavigation}
|
||||
/>
|
||||
<Paper p='xs' radius='xs' shadow='xs'>
|
||||
<Group
|
||||
justify='space-between'
|
||||
@@ -184,8 +186,8 @@ export function PageDetail({
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{computedActions && (
|
||||
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
|
||||
{computedActions.length > 0 && (
|
||||
<Group gap={5} justify='right' wrap='nowrap' align='center'>
|
||||
{computedActions.map((action, idx) => (
|
||||
<Fragment key={idx}>{action}</Fragment>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<Paper p='7' radius='xs' shadow='xs' data-testid='breadcrumb-list'>
|
||||
<Group gap='xs' justify='space-between' align='center' wrap='nowrap'>
|
||||
<BreadcrumbList
|
||||
navCallback={breadcrumbAction}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{hasDetailNavigation && (
|
||||
<DetailNavigation navigation={detailNavigation} />
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -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<PanelProps>) {
|
||||
const location = useLocation();
|
||||
const lastUsedPanel = useLocalState(
|
||||
useShallow((state) => {
|
||||
const panelName =
|
||||
@@ -527,7 +531,7 @@ function IndexPanelComponent({
|
||||
})
|
||||
);
|
||||
|
||||
return <Navigate to={lastUsedPanel} replace />;
|
||||
return <Navigate to={`${lastUsedPanel}${location.search}`} replace />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<T extends Record<string, any>>({
|
||||
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<T extends Record<string, any>>({
|
||||
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<T extends Record<string, any>>({
|
||||
...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<T extends Record<string, any>>({
|
||||
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<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -672,7 +739,7 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
// Refetch data when the query parameters change
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [searchParams]);
|
||||
}, [tableSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const loading: boolean =
|
||||
@@ -709,9 +776,16 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
|
||||
// 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<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 =
|
||||
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<T extends Record<string, any>>({
|
||||
// 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<T extends Record<string, any>>({
|
||||
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<T extends Record<string, any>>({
|
||||
props.modelType,
|
||||
props.detailAction,
|
||||
props.modelField,
|
||||
getDetailNavigationUrl,
|
||||
showPreviewPanel,
|
||||
showRowPreview,
|
||||
showContextMenu,
|
||||
navigate
|
||||
navigate,
|
||||
tableState.records
|
||||
]
|
||||
);
|
||||
|
||||
@@ -930,8 +1020,8 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
|
||||
hasSwitchableColumns={hasSwitchableColumns}
|
||||
columns={dataColumns}
|
||||
filters={filters}
|
||||
queryFilters={searchParams}
|
||||
clearQueryFilters={() => setSearchParams?.(new URLSearchParams())}
|
||||
queryFilters={tableSearchParams}
|
||||
clearQueryFilters={clearTableSearchParams}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
</Boundary>
|
||||
|
||||
@@ -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<string>(
|
||||
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<string, string>([
|
||||
[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<HTMLAnchorElement>) => 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, unknown>
|
||||
): 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<string, unknown>;
|
||||
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}`;
|
||||
}
|
||||
@@ -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<DetailNavigationData> => {
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user