Fix for details links (#7184)

* Fix for details URl

- Do not open as a new link
- Instead, use internal 'navigate'
- Otherwise, triggers a login sequence again
- Major improvement in workflow

* Fix InvenTreeTable

* Refactor

* Handle case where no model available

* Fix default return type

* Use proper mantine table components

* Fix for BomTable click-through

* Details tweaks

* Fix labels

* Implement total price detail

* Cleanup

* Rendering tweaks

* Fix for Details.tsx
This commit is contained in:
Oliver 2024-05-09 12:15:07 +10:00 committed by GitHub
parent 770dbb9c35
commit db1a2f9015
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 136 additions and 75 deletions

View File

@ -12,14 +12,15 @@ import {
Tooltip Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo } from 'react'; import { Suspense, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons'; import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls'; import { getDetailUrl } from '../../functions/urls';
import { base_url } from '../../main';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState'; import { useGlobalSettingsState } from '../../states/SettingsState';
import { YesNoButton } from '../buttons/YesNoButton'; import { YesNoButton } from '../buttons/YesNoButton';
@ -183,7 +184,7 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
function TableStringValue(props: Readonly<FieldProps>) { function TableStringValue(props: Readonly<FieldProps>) {
let value = props?.field_value; let value = props?.field_value;
if (props.field_data?.value_formatter) { if (props?.field_data?.value_formatter) {
value = props.field_data.value_formatter(); value = props.field_data.value_formatter();
} }
@ -222,24 +223,15 @@ function BooleanValue(props: Readonly<FieldProps>) {
} }
function TableAnchorValue(props: Readonly<FieldProps>) { function TableAnchorValue(props: Readonly<FieldProps>) {
if (props.field_data.external) { const navigate = useNavigate();
return (
<Anchor
href={`${props.field_value}`}
target={'_blank'}
rel={'noreferrer noopener'}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
<Text>{props.field_value}</Text>
<InvenTreeIcon icon="external" iconProps={{ size: 15 }} />
</span>
</Anchor>
);
}
const { data } = useSuspenseQuery({ const { data } = useSuspenseQuery({
queryKey: ['detail', props.field_data.model, props.field_value], queryKey: ['detail', props.field_data.model, props.field_value],
queryFn: async () => { queryFn: async () => {
if (!props.field_data?.model) {
return {};
}
const modelDef = getModelInfo(props.field_data.model); const modelDef = getModelInfo(props.field_data.model);
if (!modelDef?.api_endpoint) { if (!modelDef?.api_endpoint) {
@ -255,19 +247,44 @@ function TableAnchorValue(props: Readonly<FieldProps>) {
case 200: case 200:
return response.data; return response.data;
default: default:
return null; return {};
} }
}) })
.catch(() => { .catch(() => {
return null; return {};
}); });
} }
}); });
const detailUrl = useMemo(() => { const detailUrl = useMemo(() => {
return getDetailUrl(props.field_data.model, props.field_value); return (
props?.field_data?.model &&
getDetailUrl(props.field_data.model, props.field_value)
);
}, [props.field_data.model, props.field_value]); }, [props.field_data.model, props.field_value]);
const handleLinkClick = useCallback(
(event: any) => {
navigateToLink(detailUrl, navigate, event);
},
[detailUrl]
);
if (props.field_data.external) {
return (
<Anchor
href={`${props.field_value}`}
target={'_blank'}
rel={'noreferrer noopener'}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
<Text>{props.field_value}</Text>
<InvenTreeIcon icon="external" iconProps={{ size: 15 }} />
</span>
</Anchor>
);
}
let make_link = props.field_data?.link ?? true; let make_link = props.field_data?.link ?? true;
// Construct the "return value" for the fetched data // Construct the "return value" for the fetched data
@ -282,18 +299,14 @@ function TableAnchorValue(props: Readonly<FieldProps>) {
} }
if (value === undefined) { if (value === undefined) {
value = data?.name ?? props.field_data?.backup_value ?? 'No name defined'; value = data?.name ?? props.field_data?.backup_value ?? t`No name defined`;
make_link = false; make_link = false;
} }
return ( return (
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}> <Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
{make_link ? ( {make_link ? (
<Anchor <Anchor href="#" onClick={handleLinkClick}>
href={`/${base_url}${detailUrl}`}
target={data?.external ? '_blank' : undefined}
rel={data?.external ? 'noreferrer noopener' : undefined}
>
<Text>{value}</Text> <Text>{value}</Text>
</Anchor> </Anchor>
) : ( ) : (
@ -370,25 +383,25 @@ export function DetailsTableField({
const FieldType: any = getFieldType(field.type); const FieldType: any = getFieldType(field.type);
return ( return (
<tr style={{ verticalAlign: 'top' }}> <Table.Tr style={{ verticalAlign: 'top' }}>
<td <Table.Td
style={{ style={{
gap: '20px', width: '50',
width: '50' maxWidth: '50'
}} }}
> >
<InvenTreeIcon icon={(field.icon ?? field.name) as InvenTreeIconType} /> <InvenTreeIcon icon={(field.icon ?? field.name) as InvenTreeIconType} />
</td> </Table.Td>
<td style={{ minWidth: '25%', maxWidth: '65%' }}> <Table.Td style={{ maxWidth: '65%' }}>
<Text>{field.label}</Text> <Text>{field.label}</Text>
</td> </Table.Td>
<td style={{ width: '100%' }}> <Table.Td style={{}}>
<FieldType field_data={field} field_value={item[field.name]} /> <FieldType field_data={field} field_value={item[field.name]} />
</td> </Table.Td>
<td style={{ width: '50' }}> <Table.Td style={{ width: '50' }}>
{field.copy && <CopyField value={item[field.name]} />} {field.copy && <CopyField value={item[field.name]} />}
</td> </Table.Td>
</tr> </Table.Tr>
); );
} }
@ -405,19 +418,14 @@ export function DetailsTable({
<Paper p="xs" withBorder radius="xs"> <Paper p="xs" withBorder radius="xs">
<Stack gap="xs"> <Stack gap="xs">
{title && <StylishText size="lg">{title}</StylishText>} {title && <StylishText size="lg">{title}</StylishText>}
<Table <Table striped verticalSpacing={5} horizontalSpacing="sm">
striped <Table.Tbody>
verticalSpacing="sm"
horizontalSpacing="md"
withColumnBorders
>
<tbody>
{fields {fields
.filter((field: DetailsField) => !field.hidden) .filter((field: DetailsField) => !field.hidden)
.map((field: DetailsField, index: number) => ( .map((field: DetailsField, index: number) => (
<DetailsTableField field={field} item={item} key={index} /> <DetailsTableField field={field} item={item} key={index} />
))} ))}
</tbody> </Table.Tbody>
</Table> </Table>
</Stack> </Stack>
</Paper> </Paper>

View File

@ -163,6 +163,7 @@ const icons = {
link: IconLink, link: IconLink,
responsible: IconUserStar, responsible: IconUserStar,
pricing: IconCurrencyDollar, pricing: IconCurrencyDollar,
total_price: IconCurrencyDollar,
currency: IconCurrencyDollar, currency: IconCurrencyDollar,
stocktake: IconClipboardList, stocktake: IconClipboardList,
user: IconUser, user: IconUser,

View File

@ -0,0 +1,20 @@
import { base_url } from '../main';
import { cancelEvent } from './events';
/*
* Navigate to a provided link.
* - If the link is to be opened externally, open it in a new tab.
* - Otherwise, navigate using the provided navigate function.
*/
export const navigateToLink = (link: string, navigate: any, event: any) => {
cancelEvent(event);
if (event?.ctrlKey || event?.shiftKey) {
// Open the link in a new tab
const url = `/${base_url}${link}`;
window.open(url, '_blank');
} else {
// Navigate internally
navigate(link);
}
};

View File

@ -102,7 +102,8 @@ export default function CategoryDetail({}: {}) {
type: 'text', type: 'text',
name: 'part_count', name: 'part_count',
label: t`Parts`, label: t`Parts`,
icon: 'part' icon: 'part',
value_formatter: () => category?.part_count || '0'
}, },
{ {
type: 'text', type: 'text',
@ -233,7 +234,7 @@ export default function CategoryDetail({}: {}) {
/> />
<PageDetail <PageDetail
title={t`Part Category`} title={t`Part Category`}
detail={<Text>{category.name ?? 'Top level'}</Text>} subtitle={category?.name}
breadcrumbs={breadcrumbs} breadcrumbs={breadcrumbs}
breadcrumbAction={() => { breadcrumbAction={() => {
setTreeOpen(true); setTreeOpen(true);

View File

@ -28,6 +28,7 @@ import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -151,18 +152,23 @@ export default function PurchaseOrderDetail() {
label: t`Completed Shipments`, label: t`Completed Shipments`,
total: order.shipments, total: order.shipments,
progress: order.completed_shipments progress: order.completed_shipments
// TODO: Fix this progress bar
}, },
{ {
type: 'text', type: 'text',
name: 'currency', name: 'currency',
label: t`Order Currency,` label: t`Order Currency`,
value_formatter: () =>
order?.order_currency ?? order?.supplier_detail?.currency
}, },
{ {
type: 'text', type: 'text',
name: 'total_cost', name: 'total_price',
label: t`Total Cost` label: t`Total Cost`,
// TODO: Implement this! value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency ?? order?.supplier_detail?.currency
});
}
} }
]; ];

View File

@ -23,6 +23,7 @@ import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -123,13 +124,19 @@ export default function ReturnOrderDetail() {
{ {
type: 'text', type: 'text',
name: 'currency', name: 'currency',
label: t`Order Currency,` label: t`Order Currency`,
value_formatter: () =>
order?.order_currency ?? order?.customer_detail?.currency
}, },
{ {
type: 'text', type: 'text',
name: 'total_cost', name: 'total_price',
label: t`Total Cost` label: t`Total Cost`,
// TODO: Implement this! value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency ?? order?.customer_detail?.currency
});
}
} }
]; ];

View File

@ -26,6 +26,7 @@ import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -127,13 +128,19 @@ export default function SalesOrderDetail() {
{ {
type: 'text', type: 'text',
name: 'currency', name: 'currency',
label: t`Order Currency,` label: t`Order Currency`,
value_formatter: () =>
order?.order_currency ?? order?.customer_detail.currency
}, },
{ {
type: 'text', type: 'text',
name: 'total_cost', name: 'total_price',
label: t`Total Cost` label: t`Total Cost`,
// TODO: Implement this! value_formatter: () => {
return formatCurrency(order?.total_price, {
currency: order?.order_currency ?? order?.customer_detail?.currency
});
}
} }
]; ];

View File

@ -108,7 +108,8 @@ export default function Stock() {
type: 'text', type: 'text',
name: 'items', name: 'items',
icon: 'stock', icon: 'stock',
label: t`Stock Items` label: t`Stock Items`,
value_formatter: () => location?.items || '0'
}, },
{ {
type: 'text', type: 'text',
@ -311,7 +312,7 @@ export default function Stock() {
/> />
<PageDetail <PageDetail
title={t`Stock Items`} title={t`Stock Items`}
detail={<Text>{location.name ?? 'Top level'}</Text>} subtitle={location?.name}
actions={locationActions} actions={locationActions}
breadcrumbs={breadcrumbs} breadcrumbs={breadcrumbs}
breadcrumbAction={() => { breadcrumbAction={() => {

View File

@ -25,7 +25,13 @@ import {
DataTableCellClickHandler, DataTableCellClickHandler,
DataTableSortStatus DataTableSortStatus
} from 'mantine-datatable'; } from 'mantine-datatable';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import React, {
Fragment,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../App'; import { api } from '../App';
@ -35,7 +41,9 @@ import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ModelType } from '../enums/ModelType'; import { ModelType } from '../enums/ModelType';
import { resolveItem } from '../functions/conversion'; import { resolveItem } from '../functions/conversion';
import { cancelEvent } from '../functions/events';
import { extractAvailableFields, mapFields } from '../functions/forms'; import { extractAvailableFields, mapFields } from '../functions/forms';
import { navigateToLink } from '../functions/navigation';
import { getDetailUrl } from '../functions/urls'; import { getDetailUrl } from '../functions/urls';
import { TableState } from '../hooks/UseTable'; import { TableState } from '../hooks/UseTable';
import { base_url } from '../main'; import { base_url } from '../main';
@ -525,7 +533,17 @@ export function InvenTreeTable<T = any>({
// Callback when a row is clicked // Callback when a row is clicked
const handleRowClick = useCallback( const handleRowClick = useCallback(
({ event, record, index }: { event: any; record: any; index: number }) => { ({
event,
record,
index
}: {
event: React.MouseEvent;
record: any;
index: number;
}) => {
cancelEvent(event);
if (props.onRowClick) { if (props.onRowClick) {
// If a custom row click handler is provided, use that // If a custom row click handler is provided, use that
props.onRowClick(record, index, event); props.onRowClick(record, index, event);
@ -536,16 +554,7 @@ export function InvenTreeTable<T = any>({
if (pk) { if (pk) {
// If a model type is provided, navigate to the detail view for that model // If a model type is provided, navigate to the detail view for that model
let url = getDetailUrl(tableProps.modelType, pk); let url = getDetailUrl(tableProps.modelType, pk);
navigateToLink(url, navigate, event);
// Should it be opened in a new tab?
if (event?.ctrlKey || event?.shiftKey) {
// Open in a new tab
url = `/${base_url}${url}`;
window.open(url, '_blank');
} else {
// Navigate internally
navigate(url);
}
} }
} }
}, },

View File

@ -419,6 +419,7 @@ export function BomTable({
tableActions: tableActions, tableActions: tableActions,
tableFilters: tableFilters, tableFilters: tableFilters,
modelType: ModelType.part, modelType: ModelType.part,
modelField: 'sub_part',
rowActions: rowActions rowActions: rowActions
}} }}
/> />