mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 04:26:44 +00:00
[React] API Image Functionality (#5696)
* Improvements to API handling on react UI - Do not force "/api/" prefix to the base URL of the server - We will need to fetch media files from the server (at /media/) - Extend API URL helper functions * Update some more hard-coded URLs * Fix search API endpoint * Fix div for panel tab * Fix debug msg * Allow CORS request to /media/ * Add ApiImage component - Used to fetch images from API which require auth - Requires some tweaks to back-end CORS settings - Otherwrise, image loading won't work on new API * Update build order table * Remove debug code * Update part detail page
This commit is contained in:
parent
65e9ba0633
commit
4de51f4f4f
@ -139,8 +139,8 @@ ALLOWED_HOSTS = get_setting(
|
|||||||
|
|
||||||
# Cross Origin Resource Sharing (CORS) options
|
# Cross Origin Resource Sharing (CORS) options
|
||||||
|
|
||||||
# Only allow CORS access to API
|
# Only allow CORS access to API and media endpoints
|
||||||
CORS_URLS_REGEX = r'^/api/.*$'
|
CORS_URLS_REGEX = r'^/(api|media)/.*$'
|
||||||
|
|
||||||
# Extract CORS options from configuration file
|
# Extract CORS options from configuration file
|
||||||
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
|
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
|
||||||
|
@ -65,11 +65,6 @@ export function RelatedModelField({
|
|||||||
if (formPk != null) {
|
if (formPk != null) {
|
||||||
let url = (definition.api_url || '') + formPk + '/';
|
let url = (definition.api_url || '') + formPk + '/';
|
||||||
|
|
||||||
// TODO: Fix this!!
|
|
||||||
if (url.startsWith('/api')) {
|
|
||||||
url = url.substring(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
api.get(url).then((response) => {
|
api.get(url).then((response) => {
|
||||||
let data = response.data;
|
let data = response.data;
|
||||||
|
|
||||||
@ -105,13 +100,6 @@ export function RelatedModelField({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Fix this in the api controller
|
|
||||||
let url = definition.api_url;
|
|
||||||
|
|
||||||
if (url.startsWith('/api')) {
|
|
||||||
url = url.substring(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
let filters = definition.filters ?? {};
|
let filters = definition.filters ?? {};
|
||||||
|
|
||||||
if (definition.adjustFilters) {
|
if (definition.adjustFilters) {
|
||||||
@ -126,7 +114,7 @@ export function RelatedModelField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return api
|
return api
|
||||||
.get(url, {
|
.get(definition.api_url, {
|
||||||
params: params
|
params: params
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
60
src/frontend/src/components/images/ApiImage.tsx
Normal file
60
src/frontend/src/components/images/ApiImage.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Component for loading an image from the InvenTree server,
|
||||||
|
* using the API's token authentication.
|
||||||
|
*
|
||||||
|
* Image caching is handled automagically by the browsers cache
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ImageProps,
|
||||||
|
LoadingOverlay,
|
||||||
|
Overlay,
|
||||||
|
Stack
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../../App';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct an image container which will load and display the image
|
||||||
|
*/
|
||||||
|
export function ApiImage(props: ImageProps) {
|
||||||
|
const [image, setImage] = useState<string>('');
|
||||||
|
|
||||||
|
const imgQuery = useQuery({
|
||||||
|
queryKey: ['image', props.src],
|
||||||
|
enabled: props.src != undefined && props.src != null && props.src != '',
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!props.src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
.get(props.src, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
let img = new Blob([response.data], {
|
||||||
|
type: response.headers['content-type']
|
||||||
|
});
|
||||||
|
let url = URL.createObjectURL(img);
|
||||||
|
setImage(url);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`Error fetching image ${props.src}:`, error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<LoadingOverlay visible={imgQuery.isLoading || imgQuery.isFetching} />
|
||||||
|
<Image {...props} src={image} />
|
||||||
|
{imgQuery.isError && <Overlay color="#F00" />}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
@ -2,8 +2,9 @@ import { t } from '@lingui/macro';
|
|||||||
import { Anchor, Image } from '@mantine/core';
|
import { Anchor, Image } from '@mantine/core';
|
||||||
import { Group } from '@mantine/core';
|
import { Group } from '@mantine/core';
|
||||||
import { Text } from '@mantine/core';
|
import { Text } from '@mantine/core';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { ApiImage } from './ApiImage';
|
||||||
|
|
||||||
export function Thumbnail({
|
export function Thumbnail({
|
||||||
src,
|
src,
|
||||||
@ -16,12 +17,9 @@ export function Thumbnail({
|
|||||||
}) {
|
}) {
|
||||||
// TODO: Use HoverCard to display a larger version of the image
|
// TODO: Use HoverCard to display a larger version of the image
|
||||||
|
|
||||||
// TODO: This is a hack until we work out the /api/ path issue
|
|
||||||
let url = api.getUri({ url: '..' + src });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<ApiImage
|
||||||
src={url}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
width={size}
|
width={size}
|
||||||
fit="contain"
|
fit="contain"
|
||||||
@ -49,20 +47,21 @@ export function ThumbnailHoverCard({
|
|||||||
alt?: string;
|
alt?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
function MainGroup() {
|
const card = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<Group position="left" spacing={10}>
|
<Group position="left" spacing={10} noWrap={true}>
|
||||||
<Thumbnail src={src} alt={alt} size={size} />
|
<Thumbnail src={src} alt={alt} size={size} />
|
||||||
<Text>{text}</Text>
|
<Text>{text}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}, [src, text, alt, size]);
|
||||||
|
|
||||||
if (link)
|
if (link)
|
||||||
return (
|
return (
|
||||||
<Anchor href={link} style={{ textDecoration: 'none' }}>
|
<Anchor href={link} style={{ textDecoration: 'none' }}>
|
||||||
<MainGroup />
|
{card}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
);
|
);
|
||||||
return <MainGroup />;
|
|
||||||
|
return <div>{card}</div>;
|
||||||
}
|
}
|
@ -17,7 +17,7 @@ export function PageDetail({
|
|||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
actions
|
actions
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
detail?: ReactNode;
|
detail?: ReactNode;
|
||||||
breadcrumbs?: Breadcrumb[];
|
breadcrumbs?: Breadcrumb[];
|
||||||
@ -34,13 +34,15 @@ export function PageDetail({
|
|||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<Group position="apart">
|
<Group position="apart">
|
||||||
<Group position="left">
|
<Group position="left">
|
||||||
<StylishText size="xl">{title}</StylishText>
|
<Stack spacing="xs">
|
||||||
{subtitle && <Text size="lg">{subtitle}</Text>}
|
{title && <StylishText size="xl">{title}</StylishText>}
|
||||||
|
{subtitle && <Text size="lg">{subtitle}</Text>}
|
||||||
|
{detail}
|
||||||
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Space />
|
<Space />
|
||||||
{actions && <Group position="right">{actions}</Group>}
|
{actions && <Group position="right">{actions}</Group>}
|
||||||
</Group>
|
</Group>
|
||||||
{detail}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -3,7 +3,7 @@ import { Alert } from '@mantine/core';
|
|||||||
import { Group, Text } from '@mantine/core';
|
import { Group, Text } from '@mantine/core';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { Thumbnail } from '../items/Thumbnail';
|
import { Thumbnail } from '../images/Thumbnail';
|
||||||
import { RenderBuildOrder } from './Build';
|
import { RenderBuildOrder } from './Build';
|
||||||
import {
|
import {
|
||||||
RenderAddress,
|
RenderAddress,
|
||||||
|
@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||||
import { ThumbnailHoverCard } from '../items/Thumbnail';
|
import { ThumbnailHoverCard } from '../images/Thumbnail';
|
||||||
|
|
||||||
export function GeneralRenderer({
|
export function GeneralRenderer({
|
||||||
api_key,
|
api_key,
|
||||||
|
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||||
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
||||||
|
import { ThumbnailHoverCard } from '../../images/Thumbnail';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { TableFilter } from '../Filter';
|
import { TableFilter } from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
@ -28,12 +29,12 @@ function buildOrderTableColumns(): TableColumn[] {
|
|||||||
let part = record.part_detail;
|
let part = record.part_detail;
|
||||||
return (
|
return (
|
||||||
part && (
|
part && (
|
||||||
<Text>{part.full_name}</Text>
|
<ThumbnailHoverCard
|
||||||
// <ThumbnailHoverCard
|
src={part.thumbnail || part.image}
|
||||||
// src={part.thumbnail || part.image}
|
text={part.full_name}
|
||||||
// text={part.full_name}
|
alt={part.description}
|
||||||
// link=""
|
link=""
|
||||||
// />
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Text } from '@mantine/core';
|
import { Group, Text } from '@mantine/core';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { notYetImplemented } from '../../../functions/notifications';
|
|||||||
import { shortenString } from '../../../functions/tables';
|
import { shortenString } from '../../../functions/tables';
|
||||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||||
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
||||||
|
import { Thumbnail } from '../../images/Thumbnail';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { TableFilter } from '../Filter';
|
import { TableFilter } from '../Filter';
|
||||||
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
|
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
|
||||||
@ -26,7 +27,14 @@ function partTableColumns(): TableColumn[] {
|
|||||||
render: function (record: any) {
|
render: function (record: any) {
|
||||||
// TODO - Link to the part detail page
|
// TODO - Link to the part detail page
|
||||||
return (
|
return (
|
||||||
<Text>{record.full_name}</Text>
|
<Group spacing="xs" align="left" noWrap={true}>
|
||||||
|
<Thumbnail
|
||||||
|
src={record.thumbnail || record.image}
|
||||||
|
alt={record.name}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<Text>{record.full_name}</Text>
|
||||||
|
</Group>
|
||||||
// <ThumbnailHoverCard
|
// <ThumbnailHoverCard
|
||||||
// src={record.thumbnail || record.image}
|
// src={record.thumbnail || record.image}
|
||||||
// text={record.name}
|
// text={record.name}
|
||||||
|
@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
|
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
|
||||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||||
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
||||||
import { Thumbnail } from '../../items/Thumbnail';
|
import { Thumbnail } from '../../images/Thumbnail';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
|
||||||
@ -35,6 +35,8 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
|
|||||||
let part = getPart(record);
|
let part = getPart(record);
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
|
noWrap={true}
|
||||||
|
position="left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/part/${part.pk}/`);
|
navigate(`/part/${part.pk}/`);
|
||||||
}}
|
}}
|
||||||
|
@ -16,11 +16,15 @@ import { ApiPaths, apiUrl } from '../states/ApiState';
|
|||||||
export function useInstance({
|
export function useInstance({
|
||||||
endpoint,
|
endpoint,
|
||||||
pk,
|
pk,
|
||||||
params = {}
|
params = {},
|
||||||
|
refetchOnMount = false,
|
||||||
|
refetchOnWindowFocus = false
|
||||||
}: {
|
}: {
|
||||||
endpoint: ApiPaths;
|
endpoint: ApiPaths;
|
||||||
pk: string | undefined;
|
pk: string | undefined;
|
||||||
params?: any;
|
params?: any;
|
||||||
|
refetchOnMount?: boolean;
|
||||||
|
refetchOnWindowFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [instance, setInstance] = useState<any>({});
|
const [instance, setInstance] = useState<any>({});
|
||||||
|
|
||||||
@ -54,8 +58,8 @@ export function useInstance({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
refetchOnMount: false,
|
refetchOnMount: refetchOnMount,
|
||||||
refetchOnWindowFocus: false
|
refetchOnWindowFocus: refetchOnWindowFocus
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshInstance = useCallback(function () {
|
const refreshInstance = useCallback(function () {
|
||||||
|
@ -1,5 +1,12 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Alert, Button, LoadingOverlay, Stack, Text } from '@mantine/core';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
LoadingOverlay,
|
||||||
|
Stack,
|
||||||
|
Text
|
||||||
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
@ -18,8 +25,11 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { api } from '../../App';
|
||||||
|
import { ApiImage } from '../../components/images/ApiImage';
|
||||||
|
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||||
import { PlaceholderPanel } from '../../components/items/Placeholder';
|
import { PlaceholderPanel } from '../../components/items/Placeholder';
|
||||||
import { PageDetail } from '../../components/nav/PageDetail';
|
import { PageDetail } from '../../components/nav/PageDetail';
|
||||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||||
@ -46,7 +56,9 @@ export default function PartDetail() {
|
|||||||
pk: id,
|
pk: id,
|
||||||
params: {
|
params: {
|
||||||
path_detail: true
|
path_detail: true
|
||||||
}
|
},
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Part data panels (recalculate when part data changes)
|
// Part data panels (recalculate when part data changes)
|
||||||
@ -185,18 +197,31 @@ export default function PartDetail() {
|
|||||||
[part]
|
[part]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const partDetail = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Group spacing="xs" noWrap={true}>
|
||||||
|
<ApiImage
|
||||||
|
src={String(part.image || '')}
|
||||||
|
radius="sm"
|
||||||
|
height={64}
|
||||||
|
width={64}
|
||||||
|
/>
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Text size="lg" weight={500}>
|
||||||
|
{part.full_name}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{part.description}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [part, id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<LoadingOverlay visible={instanceQuery.isFetching} />
|
<LoadingOverlay visible={instanceQuery.isFetching} />
|
||||||
<PageDetail
|
<PageDetail
|
||||||
title={t`Part`}
|
detail={partDetail}
|
||||||
subtitle={part.full_name}
|
|
||||||
detail={
|
|
||||||
<Alert color="teal" title="Part detail goes here">
|
|
||||||
<Text>TODO: Part details</Text>
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
breadcrumbs={breadcrumbs}
|
breadcrumbs={breadcrumbs}
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
<Button
|
||||||
|
Loading…
x
Reference in New Issue
Block a user