[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:
Oliver 2023-10-16 19:44:09 +11:00 committed by GitHub
parent 65e9ba0633
commit 4de51f4f4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 143 additions and 54 deletions

View File

@ -139,8 +139,8 @@ ALLOWED_HOSTS = get_setting(
# Cross Origin Resource Sharing (CORS) options
# Only allow CORS access to API
CORS_URLS_REGEX = r'^/api/.*$'
# Only allow CORS access to API and media endpoints
CORS_URLS_REGEX = r'^/(api|media)/.*$'
# Extract CORS options from configuration file
CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(

View File

@ -65,11 +65,6 @@ export function RelatedModelField({
if (formPk != null) {
let url = (definition.api_url || '') + formPk + '/';
// TODO: Fix this!!
if (url.startsWith('/api')) {
url = url.substring(4);
}
api.get(url).then((response) => {
let data = response.data;
@ -105,13 +100,6 @@ export function RelatedModelField({
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 ?? {};
if (definition.adjustFilters) {
@ -126,7 +114,7 @@ export function RelatedModelField({
};
return api
.get(url, {
.get(definition.api_url, {
params: params
})
.then((response) => {

View 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>
);
}

View File

@ -2,8 +2,9 @@ import { t } from '@lingui/macro';
import { Anchor, Image } from '@mantine/core';
import { Group } from '@mantine/core';
import { Text } from '@mantine/core';
import { useMemo } from 'react';
import { api } from '../../App';
import { ApiImage } from './ApiImage';
export function Thumbnail({
src,
@ -16,12 +17,9 @@ export function Thumbnail({
}) {
// 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 (
<Image
src={url}
<ApiImage
src={src}
alt={alt}
width={size}
fit="contain"
@ -49,20 +47,21 @@ export function ThumbnailHoverCard({
alt?: string;
size?: number;
}) {
function MainGroup() {
const card = useMemo(() => {
return (
<Group position="left" spacing={10}>
<Group position="left" spacing={10} noWrap={true}>
<Thumbnail src={src} alt={alt} size={size} />
<Text>{text}</Text>
</Group>
);
}
}, [src, text, alt, size]);
if (link)
return (
<Anchor href={link} style={{ textDecoration: 'none' }}>
<MainGroup />
{card}
</Anchor>
);
return <MainGroup />;
return <div>{card}</div>;
}

View File

@ -17,7 +17,7 @@ export function PageDetail({
breadcrumbs,
actions
}: {
title: string;
title?: string;
subtitle?: string;
detail?: ReactNode;
breadcrumbs?: Breadcrumb[];
@ -34,13 +34,15 @@ export function PageDetail({
<Stack spacing="xs">
<Group position="apart">
<Group position="left">
<StylishText size="xl">{title}</StylishText>
{subtitle && <Text size="lg">{subtitle}</Text>}
<Stack spacing="xs">
{title && <StylishText size="xl">{title}</StylishText>}
{subtitle && <Text size="lg">{subtitle}</Text>}
{detail}
</Stack>
</Group>
<Space />
{actions && <Group position="right">{actions}</Group>}
</Group>
{detail}
</Stack>
</Paper>
</Stack>

View File

@ -3,7 +3,7 @@ import { Alert } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import { ReactNode } from 'react';
import { Thumbnail } from '../items/Thumbnail';
import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildOrder } from './Build';
import {
RenderAddress,

View File

@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../../App';
import { ApiPaths, apiUrl } from '../../states/ApiState';
import { ThumbnailHoverCard } from '../items/Thumbnail';
import { ThumbnailHoverCard } from '../images/Thumbnail';
export function GeneralRenderer({
api_key,

View File

@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { ThumbnailHoverCard } from '../../images/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@ -28,12 +29,12 @@ function buildOrderTableColumns(): TableColumn[] {
let part = record.part_detail;
return (
part && (
<Text>{part.full_name}</Text>
// <ThumbnailHoverCard
// src={part.thumbnail || part.image}
// text={part.full_name}
// link=""
// />
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
@ -8,6 +8,7 @@ import { notYetImplemented } from '../../../functions/notifications';
import { shortenString } from '../../../functions/tables';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
@ -26,7 +27,14 @@ function partTableColumns(): TableColumn[] {
render: function (record: any) {
// TODO - Link to the part detail page
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
// src={record.thumbnail || record.image}
// text={record.name}

View File

@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../items/Thumbnail';
import { Thumbnail } from '../../images/Thumbnail';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
@ -35,6 +35,8 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
let part = getPart(record);
return (
<Group
noWrap={true}
position="left"
onClick={() => {
navigate(`/part/${part.pk}/`);
}}

View File

@ -16,11 +16,15 @@ import { ApiPaths, apiUrl } from '../states/ApiState';
export function useInstance({
endpoint,
pk,
params = {}
params = {},
refetchOnMount = false,
refetchOnWindowFocus = false
}: {
endpoint: ApiPaths;
pk: string | undefined;
params?: any;
refetchOnMount?: boolean;
refetchOnWindowFocus?: boolean;
}) {
const [instance, setInstance] = useState<any>({});
@ -54,8 +58,8 @@ export function useInstance({
return null;
});
},
refetchOnMount: false,
refetchOnWindowFocus: false
refetchOnMount: refetchOnMount,
refetchOnWindowFocus: refetchOnWindowFocus
});
const refreshInstance = useCallback(function () {

View File

@ -1,5 +1,12 @@
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 {
IconBuilding,
IconCurrencyDollar,
@ -18,8 +25,11 @@ import {
} from '@tabler/icons-react';
import React 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 { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
@ -46,7 +56,9 @@ export default function PartDetail() {
pk: id,
params: {
path_detail: true
}
},
refetchOnMount: true,
refetchOnWindowFocus: true
});
// Part data panels (recalculate when part data changes)
@ -185,18 +197,31 @@ export default function PartDetail() {
[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 (
<>
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
title={t`Part`}
subtitle={part.full_name}
detail={
<Alert color="teal" title="Part detail goes here">
<Text>TODO: Part details</Text>
</Alert>
}
detail={partDetail}
breadcrumbs={breadcrumbs}
actions={[
<Button