mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-13 14:11:37 +00:00
[UI] Enhance stock item rendering (#10143)
* Enhance stock item rendering - Show location in forms - Allow differentiation between items * Unit test fixes - Account for longer query time in docker mode * Cast to string * Improved rendering
This commit is contained in:
@@ -8,6 +8,7 @@ export interface InstanceRenderInterface {
|
|||||||
link?: boolean;
|
link?: boolean;
|
||||||
navigate?: any;
|
navigate?: any;
|
||||||
showSecondary?: boolean;
|
showSecondary?: boolean;
|
||||||
|
extra?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnumDictionary<T extends string | symbol | number, U> = {
|
type EnumDictionary<T extends string | symbol | number, U> = {
|
||||||
|
@@ -1,5 +1,14 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Alert, Anchor, Group, Skeleton, Space, Text } from '@mantine/core';
|
import {
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Group,
|
||||||
|
type MantineSize,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Text
|
||||||
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ReactNode, useCallback } from 'react';
|
import { type ReactNode, useCallback } from 'react';
|
||||||
|
|
||||||
@@ -158,8 +167,8 @@ export function RenderInlineModel({
|
|||||||
showSecondary = true,
|
showSecondary = true,
|
||||||
tooltip
|
tooltip
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
primary: string;
|
primary: ReactNode;
|
||||||
secondary?: string;
|
secondary?: ReactNode;
|
||||||
showSecondary?: boolean;
|
showSecondary?: boolean;
|
||||||
prefix?: ReactNode;
|
prefix?: ReactNode;
|
||||||
suffix?: ReactNode;
|
suffix?: ReactNode;
|
||||||
@@ -180,15 +189,25 @@ export function RenderInlineModel({
|
|||||||
[url, navigate]
|
[url, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryText = shortenString({
|
if (typeof primary === 'string') {
|
||||||
str: primary,
|
primary = shortenString({
|
||||||
len: 50
|
str: primary,
|
||||||
});
|
len: 50
|
||||||
|
});
|
||||||
|
|
||||||
const secondaryText = shortenString({
|
primary = <Text size='sm'>{primary}</Text>;
|
||||||
str: secondary,
|
}
|
||||||
len: 75
|
|
||||||
});
|
if (typeof secondary === 'string') {
|
||||||
|
secondary = shortenString({
|
||||||
|
str: secondary,
|
||||||
|
len: 75
|
||||||
|
});
|
||||||
|
|
||||||
|
if (secondary.toString()?.length > 0) {
|
||||||
|
secondary = <InlineSecondaryBadge text={secondary.toString()} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
|
<Group gap='xs' justify='space-between' wrap='nowrap' title={tooltip}>
|
||||||
@@ -197,12 +216,12 @@ export function RenderInlineModel({
|
|||||||
{image && <Thumbnail src={image} size={18} />}
|
{image && <Thumbnail src={image} size={18} />}
|
||||||
{url ? (
|
{url ? (
|
||||||
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
<Anchor href='' onClick={(event: any) => onClick(event)}>
|
||||||
<Text size='sm'>{primaryText}</Text>
|
{primary}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
) : (
|
) : (
|
||||||
<Text size='sm'>{primaryText}</Text>
|
primary
|
||||||
)}
|
)}
|
||||||
{showSecondary && secondary && <Text size='xs'>{secondaryText}</Text>}
|
{showSecondary && secondary && secondary}
|
||||||
</Group>
|
</Group>
|
||||||
{suffix && (
|
{suffix && (
|
||||||
<>
|
<>
|
||||||
@@ -222,3 +241,29 @@ export function UnknownRenderer({
|
|||||||
const model_name = model ? model.toString() : 'undefined';
|
const model_name = model ? model.toString() : 'undefined';
|
||||||
return <Alert color='red' title={t`Unknown model: ${model_name}`} />;
|
return <Alert color='red' title={t`Unknown model: ${model_name}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a "badge like" component with a text label
|
||||||
|
*/
|
||||||
|
export function InlineSecondaryBadge({
|
||||||
|
text,
|
||||||
|
title,
|
||||||
|
size = 'xs'
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
title?: string;
|
||||||
|
size?: MantineSize;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<Paper p={2} withBorder style={{ backgroundColor: 'transparent' }}>
|
||||||
|
<Group gap='xs'>
|
||||||
|
{title && (
|
||||||
|
<Text size={size} title={title}>
|
||||||
|
{title}:
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size={size ?? 'xs'}>{text}</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Badge } from '@mantine/core';
|
import { Badge, Group, Text } from '@mantine/core';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { formatDecimal } from '@lib/functions/Formatting';
|
import { formatDecimal } from '@lib/functions/Formatting';
|
||||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||||
|
import { shortenString } from '../../functions/tables';
|
||||||
|
import { TableHoverCard } from '../../tables/TableHoverCard';
|
||||||
import { ApiIcon } from '../items/ApiIcon';
|
import { ApiIcon } from '../items/ApiIcon';
|
||||||
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
|
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
|
||||||
|
|
||||||
@@ -58,6 +60,24 @@ export function RenderPartCategory(
|
|||||||
): ReactNode {
|
): ReactNode {
|
||||||
const { instance } = props;
|
const { instance } = props;
|
||||||
|
|
||||||
|
const suffix: ReactNode = (
|
||||||
|
<Group gap='xs'>
|
||||||
|
<TableHoverCard
|
||||||
|
value=''
|
||||||
|
position='bottom-end'
|
||||||
|
zIndex={10000}
|
||||||
|
icon='sitemap'
|
||||||
|
title={t`Category`}
|
||||||
|
extra={[<Text>{instance.pathstring}</Text>]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
const category = shortenString({
|
||||||
|
str: instance.pathstring,
|
||||||
|
len: 50
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
{...props}
|
{...props}
|
||||||
@@ -68,8 +88,9 @@ export function RenderPartCategory(
|
|||||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
primary={instance.pathstring}
|
primary={category}
|
||||||
secondary={instance.description}
|
secondary={instance.description}
|
||||||
|
suffix={suffix}
|
||||||
url={
|
url={
|
||||||
props.link
|
props.link
|
||||||
? getDetailUrl(ModelType.partcategory, instance.pk)
|
? getDetailUrl(ModelType.partcategory, instance.pk)
|
||||||
|
@@ -1,12 +1,18 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Text } from '@mantine/core';
|
import { Group, Text } from '@mantine/core';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { formatDecimal } from '@lib/functions/Formatting';
|
import { formatDecimal } from '@lib/functions/Formatting';
|
||||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||||
|
import { shortenString } from '../../functions/tables';
|
||||||
|
import { TableHoverCard } from '../../tables/TableHoverCard';
|
||||||
import { ApiIcon } from '../items/ApiIcon';
|
import { ApiIcon } from '../items/ApiIcon';
|
||||||
import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
|
import {
|
||||||
|
InlineSecondaryBadge,
|
||||||
|
type InstanceRenderInterface,
|
||||||
|
RenderInlineModel
|
||||||
|
} from './Instance';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline rendering of a single StockLocation instance
|
* Inline rendering of a single StockLocation instance
|
||||||
@@ -16,6 +22,24 @@ export function RenderStockLocation(
|
|||||||
): ReactNode {
|
): ReactNode {
|
||||||
const { instance } = props;
|
const { instance } = props;
|
||||||
|
|
||||||
|
const suffix: ReactNode = (
|
||||||
|
<Group gap='xs'>
|
||||||
|
<TableHoverCard
|
||||||
|
value=''
|
||||||
|
position='bottom-end'
|
||||||
|
zIndex={10000}
|
||||||
|
icon='sitemap'
|
||||||
|
title={t`Location`}
|
||||||
|
extra={[<Text>{instance.pathstring}</Text>]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
const location = shortenString({
|
||||||
|
str: instance.pathstring,
|
||||||
|
len: 50
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
{...props}
|
{...props}
|
||||||
@@ -26,8 +50,9 @@ export function RenderStockLocation(
|
|||||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
primary={instance.pathstring}
|
primary={location}
|
||||||
secondary={instance.description}
|
secondary={instance.description}
|
||||||
|
suffix={suffix}
|
||||||
url={
|
url={
|
||||||
props.link
|
props.link
|
||||||
? getDetailUrl(ModelType.stocklocation, instance.pk)
|
? getDetailUrl(ModelType.stocklocation, instance.pk)
|
||||||
@@ -69,18 +94,44 @@ export function RenderStockItem(
|
|||||||
quantity_string = `${t`Quantity`}: ${formatDecimal(instance.quantity)}`;
|
quantity_string = `${t`Quantity`}: ${formatDecimal(instance.quantity)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let batch_string = '';
|
const showLocation: boolean = props.extra?.show_location !== false;
|
||||||
|
const location: any = props.instance?.location_detail;
|
||||||
|
|
||||||
if (!!instance.batch) {
|
// Form the "secondary" text to display
|
||||||
batch_string = `${t`Batch`}: ${instance.batch}`;
|
const secondary: ReactNode = (
|
||||||
}
|
<Group gap='xs' style={{ paddingLeft: '5px' }}>
|
||||||
|
{showLocation && location?.name && (
|
||||||
|
<InlineSecondaryBadge title={t`Location`} text={location.name} />
|
||||||
|
)}
|
||||||
|
{instance.batch && (
|
||||||
|
<InlineSecondaryBadge title={t`Batch`} text={instance.batch} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Form the "suffix" text to display
|
||||||
|
const suffix: ReactNode = (
|
||||||
|
<Group gap='xs'>
|
||||||
|
<Text size='xs'>{quantity_string}</Text>
|
||||||
|
{location && (
|
||||||
|
<TableHoverCard
|
||||||
|
value=''
|
||||||
|
position='bottom-end'
|
||||||
|
zIndex={10000}
|
||||||
|
icon='sitemap'
|
||||||
|
title={t`Location`}
|
||||||
|
extra={[<Text>{location.pathstring}</Text>]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RenderInlineModel
|
<RenderInlineModel
|
||||||
{...props}
|
{...props}
|
||||||
primary={instance.part_detail?.full_name}
|
primary={instance.part_detail?.full_name}
|
||||||
secondary={batch_string}
|
secondary={secondary}
|
||||||
suffix={<Text size='xs'>{quantity_string}</Text>}
|
suffix={suffix}
|
||||||
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
||||||
url={
|
url={
|
||||||
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
|
props.link ? getDetailUrl(ModelType.stockitem, instance.pk) : undefined
|
||||||
|
@@ -1,5 +1,12 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Divider, Group, HoverCard, Stack, Text } from '@mantine/core';
|
import {
|
||||||
|
Divider,
|
||||||
|
type FloatingPosition,
|
||||||
|
Group,
|
||||||
|
HoverCard,
|
||||||
|
Stack,
|
||||||
|
Text
|
||||||
|
} from '@mantine/core';
|
||||||
import { type ReactNode, useMemo } from 'react';
|
import { type ReactNode, useMemo } from 'react';
|
||||||
|
|
||||||
import type { InvenTreeIconType } from '@lib/types/Icons';
|
import type { InvenTreeIconType } from '@lib/types/Icons';
|
||||||
@@ -15,13 +22,17 @@ export function TableHoverCard({
|
|||||||
extra, // The extra information to display
|
extra, // The extra information to display
|
||||||
title, // The title of the hovercard
|
title, // The title of the hovercard
|
||||||
icon, // The icon to display
|
icon, // The icon to display
|
||||||
iconColor // The icon color
|
iconColor, // The icon color
|
||||||
|
position, // The position of the hovercard
|
||||||
|
zIndex // Optional z-index for the hovercard
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
value: any;
|
value: any;
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
icon?: keyof InvenTreeIconType;
|
icon?: keyof InvenTreeIconType;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
|
position?: FloatingPosition;
|
||||||
|
zIndex?: string | number;
|
||||||
}>) {
|
}>) {
|
||||||
const extraItems: ReactNode = useMemo(() => {
|
const extraItems: ReactNode = useMemo(() => {
|
||||||
if (Array.isArray(extra)) {
|
if (Array.isArray(extra)) {
|
||||||
@@ -47,7 +58,13 @@ export function TableHoverCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard withinPortal={true} closeDelay={20} openDelay={250}>
|
<HoverCard
|
||||||
|
withinPortal={true}
|
||||||
|
closeDelay={20}
|
||||||
|
openDelay={250}
|
||||||
|
position={position}
|
||||||
|
zIndex={zIndex}
|
||||||
|
>
|
||||||
<HoverCard.Target>
|
<HoverCard.Target>
|
||||||
<Group gap='xs' justify='space-between' wrap='nowrap'>
|
<Group gap='xs' justify='space-between' wrap='nowrap'>
|
||||||
{value}
|
{value}
|
||||||
|
Reference in New Issue
Block a user