2
0
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:
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 # 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(

View File

@ -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) => {

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 { 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>;
} }

View File

@ -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">
{title && <StylishText size="xl">{title}</StylishText>}
{subtitle && <Text size="lg">{subtitle}</Text>} {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>

View File

@ -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,

View File

@ -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,

View File

@ -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=""
// /> />
) )
); );
} }

View File

@ -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 (
<Group spacing="xs" align="left" noWrap={true}>
<Thumbnail
src={record.thumbnail || record.image}
alt={record.name}
size={24}
/>
<Text>{record.full_name}</Text> <Text>{record.full_name}</Text>
</Group>
// <ThumbnailHoverCard // <ThumbnailHoverCard
// src={record.thumbnail || record.image} // src={record.thumbnail || record.image}
// text={record.name} // text={record.name}

View File

@ -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}/`);
}} }}

View File

@ -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 () {

View File

@ -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