mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -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( | ||||
|   | ||||
| @@ -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) => { | ||||
|   | ||||
							
								
								
									
										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 { 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>; | ||||
| } | ||||
| @@ -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> | ||||
|               <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> | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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="" | ||||
|             /> | ||||
|           ) | ||||
|         ); | ||||
|       } | ||||
|   | ||||
| @@ -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 ( | ||||
|           <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} | ||||
|   | ||||
| @@ -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}/`); | ||||
|               }} | ||||
|   | ||||
| @@ -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 () { | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user