410 lines
9.9 KiB
TypeScript
410 lines
9.9 KiB
TypeScript
import { t } from '@lingui/macro';
|
|
import {
|
|
Anchor,
|
|
Badge,
|
|
Paper,
|
|
Skeleton,
|
|
Stack,
|
|
Table,
|
|
Text
|
|
} from '@mantine/core';
|
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
|
import { getValueAtPath } from 'mantine-datatable';
|
|
import { Suspense, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { api } from '../../App';
|
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
|
import { ModelType } from '../../enums/ModelType';
|
|
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
|
import { navigateToLink } from '../../functions/navigation';
|
|
import { getDetailUrl } from '../../functions/urls';
|
|
import { apiUrl } from '../../states/ApiState';
|
|
import { useGlobalSettingsState } from '../../states/SettingsState';
|
|
import { CopyButton } from '../buttons/CopyButton';
|
|
import { YesNoButton } from '../buttons/YesNoButton';
|
|
import { ProgressBar } from '../items/ProgressBar';
|
|
import { StylishText } from '../items/StylishText';
|
|
import { getModelInfo } from '../render/ModelType';
|
|
import { StatusRenderer } from '../render/StatusRenderer';
|
|
|
|
export type DetailsField =
|
|
| {
|
|
hidden?: boolean;
|
|
icon?: InvenTreeIconType;
|
|
name: string;
|
|
label?: string;
|
|
badge?: BadgeType;
|
|
copy?: boolean;
|
|
value_formatter?: () => ValueFormatterReturn;
|
|
} & (
|
|
| StringDetailField
|
|
| BooleanField
|
|
| LinkDetailField
|
|
| ProgressBarField
|
|
| StatusField
|
|
);
|
|
|
|
type BadgeType = 'owner' | 'user' | 'group';
|
|
type ValueFormatterReturn = string | number | null | React.ReactNode;
|
|
|
|
type StringDetailField = {
|
|
type: 'string' | 'text';
|
|
unit?: boolean;
|
|
};
|
|
|
|
type BooleanField = {
|
|
type: 'boolean';
|
|
};
|
|
|
|
type LinkDetailField = {
|
|
type: 'link';
|
|
link?: boolean;
|
|
} & (InternalLinkField | ExternalLinkField);
|
|
|
|
type InternalLinkField = {
|
|
model: ModelType;
|
|
model_field?: string;
|
|
model_formatter?: (value: any) => string;
|
|
backup_value?: string;
|
|
};
|
|
|
|
type ExternalLinkField = {
|
|
external: true;
|
|
};
|
|
|
|
type ProgressBarField = {
|
|
type: 'progressbar';
|
|
progress: number;
|
|
total: number;
|
|
};
|
|
|
|
type StatusField = {
|
|
type: 'status';
|
|
model: ModelType;
|
|
};
|
|
|
|
type FieldProps = {
|
|
field_data: any;
|
|
field_value: string | number;
|
|
unit?: string | null;
|
|
};
|
|
|
|
/**
|
|
* Fetches user or group info from backend and formats into a badge.
|
|
* Badge shows username, full name, or group name depending on server settings.
|
|
* Badge appends icon to describe type of Owner
|
|
*/
|
|
function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['badge', type, pk],
|
|
queryFn: async () => {
|
|
let path: string = '';
|
|
|
|
switch (type) {
|
|
case 'owner':
|
|
path = ApiEndpoints.owner_list;
|
|
break;
|
|
case 'user':
|
|
path = ApiEndpoints.user_list;
|
|
break;
|
|
case 'group':
|
|
path = ApiEndpoints.group_list;
|
|
break;
|
|
}
|
|
|
|
const url = apiUrl(path, pk);
|
|
|
|
return api
|
|
.get(url)
|
|
.then((response) => {
|
|
switch (response.status) {
|
|
case 200:
|
|
return response.data;
|
|
default:
|
|
return undefined;
|
|
}
|
|
})
|
|
.catch(() => {
|
|
return undefined;
|
|
});
|
|
}
|
|
});
|
|
|
|
const settings = useGlobalSettingsState();
|
|
|
|
// Rendering a user's rame for the badge
|
|
function _render_name() {
|
|
if (!data) {
|
|
return '';
|
|
} else if (type === 'user' && settings.isSet('DISPLAY_FULL_NAMES')) {
|
|
if (data.first_name || data.last_name) {
|
|
return `${data.first_name} ${data.last_name}`;
|
|
} else {
|
|
return data.username;
|
|
}
|
|
} else if (type === 'user') {
|
|
return data.username;
|
|
} else {
|
|
return data.name;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<Badge
|
|
color="dark"
|
|
variant="filled"
|
|
style={{ display: 'flex', alignItems: 'center' }}
|
|
>
|
|
{data?.name ?? _render_name()}
|
|
</Badge>
|
|
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the value of a 'string' or 'text' field.
|
|
* If owner is defined, only renders a badge
|
|
* If user is defined, a badge is rendered in addition to main value
|
|
*/
|
|
function TableStringValue(props: Readonly<FieldProps>) {
|
|
let value = props?.field_value;
|
|
|
|
if (props?.field_data?.value_formatter) {
|
|
value = props.field_data.value_formatter();
|
|
}
|
|
|
|
if (value === undefined) {
|
|
return '---';
|
|
}
|
|
|
|
if (props.field_data?.badge) {
|
|
return <NameBadge pk={value} type={props.field_data.badge} />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
wordBreak: 'break-word',
|
|
alignItems: 'flex-start'
|
|
}}
|
|
>
|
|
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
|
|
<span>
|
|
{value ? value : props.field_data?.unit && '0'}{' '}
|
|
{props.field_data.unit == true && props.unit}
|
|
</span>
|
|
</Suspense>
|
|
{props.field_data.user && (
|
|
<NameBadge pk={props.field_data?.user} type="user" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BooleanValue(props: Readonly<FieldProps>) {
|
|
return <YesNoButton value={props.field_value} />;
|
|
}
|
|
|
|
function TableAnchorValue(props: Readonly<FieldProps>) {
|
|
const navigate = useNavigate();
|
|
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['detail', props.field_data.model, props.field_value],
|
|
queryFn: async () => {
|
|
if (!props.field_data?.model) {
|
|
return {};
|
|
}
|
|
|
|
const modelDef = getModelInfo(props.field_data.model);
|
|
|
|
if (!modelDef?.api_endpoint) {
|
|
return {};
|
|
}
|
|
|
|
const url = apiUrl(modelDef.api_endpoint, props.field_value);
|
|
|
|
return api
|
|
.get(url)
|
|
.then((response) => {
|
|
switch (response.status) {
|
|
case 200:
|
|
return response.data;
|
|
default:
|
|
return {};
|
|
}
|
|
})
|
|
.catch(() => {
|
|
return {};
|
|
});
|
|
}
|
|
});
|
|
|
|
const detailUrl = useMemo(() => {
|
|
return (
|
|
props?.field_data?.model &&
|
|
getDetailUrl(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;
|
|
|
|
// Construct the "return value" for the fetched data
|
|
let value = undefined;
|
|
|
|
if (props.field_data.model_formatter) {
|
|
value = props.field_data.model_formatter(data) ?? value;
|
|
} else if (props.field_data.model_field) {
|
|
value = data?.[props.field_data.model_field] ?? value;
|
|
} else {
|
|
value = data?.name;
|
|
}
|
|
|
|
if (value === undefined) {
|
|
value = data?.name ?? props.field_data?.backup_value ?? t`No name defined`;
|
|
make_link = false;
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
|
|
{make_link ? (
|
|
<Anchor href="#" onClick={handleLinkClick}>
|
|
<Text>{value}</Text>
|
|
</Anchor>
|
|
) : (
|
|
<Text>{value}</Text>
|
|
)}
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function ProgressBarValue(props: Readonly<FieldProps>) {
|
|
return (
|
|
<ProgressBar
|
|
value={props.field_data.progress}
|
|
maximum={props.field_data.total}
|
|
progressLabel
|
|
/>
|
|
);
|
|
}
|
|
|
|
function StatusValue(props: Readonly<FieldProps>) {
|
|
return (
|
|
<StatusRenderer type={props.field_data.model} status={props.field_value} />
|
|
);
|
|
}
|
|
|
|
function CopyField({ value }: { value: string }) {
|
|
return <CopyButton value={value} />;
|
|
}
|
|
|
|
export function DetailsTableField({
|
|
item,
|
|
field
|
|
}: {
|
|
item: any;
|
|
field: DetailsField;
|
|
}) {
|
|
function getFieldType(type: string) {
|
|
switch (type) {
|
|
case 'text':
|
|
case 'string':
|
|
return TableStringValue;
|
|
case 'boolean':
|
|
return BooleanValue;
|
|
case 'link':
|
|
return TableAnchorValue;
|
|
case 'progressbar':
|
|
return ProgressBarValue;
|
|
case 'status':
|
|
return StatusValue;
|
|
default:
|
|
return TableStringValue;
|
|
}
|
|
}
|
|
|
|
const FieldType: any = getFieldType(field.type);
|
|
|
|
const fieldValue = useMemo(
|
|
() => getValueAtPath(item, field.name) as string,
|
|
[item, field.name]
|
|
);
|
|
|
|
return (
|
|
<Table.Tr style={{ verticalAlign: 'top' }}>
|
|
<Table.Td
|
|
style={{
|
|
width: '50',
|
|
maxWidth: '50'
|
|
}}
|
|
>
|
|
<InvenTreeIcon icon={field.icon ?? (field.name as InvenTreeIconType)} />
|
|
</Table.Td>
|
|
<Table.Td style={{ maxWidth: '65%', lineBreak: 'auto' }}>
|
|
<Text>{field.label}</Text>
|
|
</Table.Td>
|
|
<Table.Td style={{ lineBreak: 'anywhere' }}>
|
|
<FieldType field_data={field} field_value={fieldValue} />
|
|
</Table.Td>
|
|
<Table.Td style={{ width: '50' }}>
|
|
{field.copy && <CopyField value={fieldValue} />}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
}
|
|
|
|
export function DetailsTable({
|
|
item,
|
|
fields,
|
|
title
|
|
}: {
|
|
item: any;
|
|
fields: DetailsField[];
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<Paper p="xs" withBorder radius="xs">
|
|
<Stack gap="xs">
|
|
{title && <StylishText size="lg">{title}</StylishText>}
|
|
<Table striped verticalSpacing={5} horizontalSpacing="sm">
|
|
<Table.Tbody>
|
|
{fields
|
|
.filter((field: DetailsField) => !field.hidden)
|
|
.map((field: DetailsField, index: number) => (
|
|
<DetailsTableField field={field} item={item} key={index} />
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|