mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Migrate Icons to Tabler icons and integrate into PUI (#7684)
* add icon backend implementation * implement pui icon picker * integrate icons in PUI * Bump API version * PUI: add icon to detail pages top header * CUI: explain icon format and change link to tabler icons site * CUI: use new icon packs * move default icon implementation to backend * add icon template tag to use in report printing * add missing migrations * fit to previous schema with part category icon * fit to previous schema with part category icon * add icon pack plugin integration * Add custom command to migrate icons * add docs * fix: tests * fix: tests * add tests * fix: tests * fix: tests * fix: tests * fix tests * fix sonarcloud issues * add logging * remove unneded pass * significantly improve performance of icon picker component
This commit is contained in:
		| @@ -50,9 +50,10 @@ | ||||
|         "codemirror": ">=6.0.0", | ||||
|         "dayjs": "^1.11.10", | ||||
|         "embla-carousel-react": "^8.1.6", | ||||
|         "fuse.js": "^7.0.0", | ||||
|         "html5-qrcode": "^2.3.8", | ||||
|         "qrcode": "^1.5.3", | ||||
|         "mantine-datatable": "^7.11.2", | ||||
|         "qrcode": "^1.5.3", | ||||
|         "react": "^18.3.1", | ||||
|         "react-dom": "^18.3.1", | ||||
|         "react-grid-layout": "^1.4.4", | ||||
| @@ -60,6 +61,7 @@ | ||||
|         "react-is": "^18.3.1", | ||||
|         "react-router-dom": "^6.24.0", | ||||
|         "react-select": "^5.8.0", | ||||
|         "react-window": "^1.8.10", | ||||
|         "recharts": "^2.12.4", | ||||
|         "styled-components": "^6.1.11", | ||||
|         "zustand": "^4.5.4" | ||||
| @@ -77,6 +79,7 @@ | ||||
|         "@types/react-dom": "^18.3.0", | ||||
|         "@types/react-grid-layout": "^1.3.5", | ||||
|         "@types/react-router-dom": "^5.3.3", | ||||
|         "@types/react-window": "^1.8.8", | ||||
|         "@vanilla-extract/vite-plugin": "^4.0.12", | ||||
|         "@vitejs/plugin-react": "^4.3.1", | ||||
|         "babel-plugin-macros": "^3.1.0", | ||||
|   | ||||
| @@ -46,7 +46,7 @@ export type DetailsField = | ||||
|     ); | ||||
|  | ||||
| type BadgeType = 'owner' | 'user' | 'group'; | ||||
| type ValueFormatterReturn = string | number | null; | ||||
| type ValueFormatterReturn = string | number | null | React.ReactNode; | ||||
|  | ||||
| type StringDetailField = { | ||||
|   type: 'string' | 'text'; | ||||
|   | ||||
| @@ -210,7 +210,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) { | ||||
|   ); | ||||
|  | ||||
|   const templateFilters: Record<string, string> = useMemo(() => { | ||||
|     // TODO: Extract custom filters from template | ||||
|     // TODO: Extract custom filters from template (make this more generic) | ||||
|     if (template.model_type === ModelType.stockitem) { | ||||
|       return { part_detail: 'true' } as Record<string, string>; | ||||
|     } | ||||
|  | ||||
|     return {}; | ||||
|   }, [template]); | ||||
|  | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { isTrue } from '../../../functions/conversion'; | ||||
| import { ChoiceField } from './ChoiceField'; | ||||
| import DateField from './DateField'; | ||||
| import { DependentField } from './DependentField'; | ||||
| import IconField from './IconField'; | ||||
| import { NestedObjectField } from './NestedObjectField'; | ||||
| import { RelatedModelField } from './RelatedModelField'; | ||||
| import { TableField } from './TableField'; | ||||
| @@ -58,6 +59,7 @@ export type ApiFormFieldType = { | ||||
|     | 'email' | ||||
|     | 'url' | ||||
|     | 'string' | ||||
|     | 'icon' | ||||
|     | 'boolean' | ||||
|     | 'date' | ||||
|     | 'datetime' | ||||
| @@ -223,6 +225,10 @@ export function ApiFormField({ | ||||
|             onChange={onChange} | ||||
|           /> | ||||
|         ); | ||||
|       case 'icon': | ||||
|         return ( | ||||
|           <IconField definition={fieldDefinition} controller={controller} /> | ||||
|         ); | ||||
|       case 'boolean': | ||||
|         return ( | ||||
|           <Switch | ||||
|   | ||||
							
								
								
									
										352
									
								
								src/frontend/src/components/forms/fields/IconField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								src/frontend/src/components/forms/fields/IconField.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,352 @@ | ||||
| import { Trans, t } from '@lingui/macro'; | ||||
| import { | ||||
|   Box, | ||||
|   CloseButton, | ||||
|   Combobox, | ||||
|   ComboboxStore, | ||||
|   Group, | ||||
|   Input, | ||||
|   InputBase, | ||||
|   Select, | ||||
|   Stack, | ||||
|   Text, | ||||
|   TextInput, | ||||
|   useCombobox | ||||
| } from '@mantine/core'; | ||||
| import { useDebouncedValue, useElementSize } from '@mantine/hooks'; | ||||
| import { IconX } from '@tabler/icons-react'; | ||||
| import Fuse from 'fuse.js'; | ||||
| import { startTransition, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| import { FieldValues, UseControllerReturn } from 'react-hook-form'; | ||||
| import { FixedSizeGrid as Grid } from 'react-window'; | ||||
|  | ||||
| import { useIconState } from '../../../states/IconState'; | ||||
| import { ApiIcon } from '../../items/ApiIcon'; | ||||
| import { ApiFormFieldType } from './ApiFormField'; | ||||
|  | ||||
| export default function IconField({ | ||||
|   controller, | ||||
|   definition | ||||
| }: Readonly<{ | ||||
|   controller: UseControllerReturn<FieldValues, any>; | ||||
|   definition: ApiFormFieldType; | ||||
| }>) { | ||||
|   const { | ||||
|     field, | ||||
|     fieldState: { error } | ||||
|   } = controller; | ||||
|  | ||||
|   const { value } = field; | ||||
|  | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const combobox = useCombobox({ | ||||
|     onOpenedChange: (opened) => setOpen(opened) | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <Combobox store={combobox}> | ||||
|       <Combobox.Target> | ||||
|         <InputBase | ||||
|           label={definition.label} | ||||
|           description={definition.description} | ||||
|           required={definition.required} | ||||
|           error={error?.message} | ||||
|           ref={field.ref} | ||||
|           component="button" | ||||
|           type="button" | ||||
|           pointer | ||||
|           rightSection={ | ||||
|             value !== null && !definition.required ? ( | ||||
|               <CloseButton | ||||
|                 size="sm" | ||||
|                 onMouseDown={(e) => e.preventDefault()} | ||||
|                 onClick={() => field.onChange(null)} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <Combobox.Chevron /> | ||||
|             ) | ||||
|           } | ||||
|           onClick={() => combobox.toggleDropdown()} | ||||
|           rightSectionPointerEvents={value === null ? 'none' : 'all'} | ||||
|         > | ||||
|           {field.value ? ( | ||||
|             <Group gap="xs"> | ||||
|               <ApiIcon name={field.value} /> | ||||
|               <Text size="sm" c="dimmed"> | ||||
|                 {field.value} | ||||
|               </Text> | ||||
|             </Group> | ||||
|           ) : ( | ||||
|             <Input.Placeholder> | ||||
|               <Trans>No icon selected</Trans> | ||||
|             </Input.Placeholder> | ||||
|           )} | ||||
|         </InputBase> | ||||
|       </Combobox.Target> | ||||
|  | ||||
|       <Combobox.Dropdown> | ||||
|         <ComboboxDropdown | ||||
|           definition={definition} | ||||
|           value={value} | ||||
|           combobox={combobox} | ||||
|           onChange={field.onChange} | ||||
|           open={open} | ||||
|         /> | ||||
|       </Combobox.Dropdown> | ||||
|     </Combobox> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| type RenderIconType = { | ||||
|   package: string; | ||||
|   name: string; | ||||
|   tags: string[]; | ||||
|   category: string; | ||||
|   variant: string; | ||||
| }; | ||||
|  | ||||
| function ComboboxDropdown({ | ||||
|   definition, | ||||
|   value, | ||||
|   combobox, | ||||
|   onChange, | ||||
|   open | ||||
| }: Readonly<{ | ||||
|   definition: ApiFormFieldType; | ||||
|   value: null | string; | ||||
|   combobox: ComboboxStore; | ||||
|   onChange: (newVal: string | null) => void; | ||||
|   open: boolean; | ||||
| }>) { | ||||
|   const iconPacks = useIconState((s) => s.packages); | ||||
|   const icons = useMemo<RenderIconType[]>(() => { | ||||
|     return iconPacks.flatMap((pack) => | ||||
|       Object.entries(pack.icons).flatMap(([name, icon]) => | ||||
|         Object.entries(icon.variants).map(([variant]) => ({ | ||||
|           package: pack.prefix, | ||||
|           name: `${pack.prefix}:${name}:${variant}`, | ||||
|           tags: icon.tags, | ||||
|           category: icon.category, | ||||
|           variant: variant | ||||
|         })) | ||||
|       ) | ||||
|     ); | ||||
|   }, [iconPacks]); | ||||
|   const filter = useMemo( | ||||
|     () => | ||||
|       new Fuse(icons, { | ||||
|         threshold: 0.2, | ||||
|         keys: ['name', 'tags', 'category', 'variant'] | ||||
|       }), | ||||
|     [icons] | ||||
|   ); | ||||
|  | ||||
|   const [searchValue, setSearchValue] = useState(''); | ||||
|   const [debouncedSearchValue] = useDebouncedValue(searchValue, 200); | ||||
|   const [category, setCategory] = useState<string | null>(null); | ||||
|   const [pack, setPack] = useState<string | null>(null); | ||||
|  | ||||
|   const categories = useMemo( | ||||
|     () => | ||||
|       Array.from( | ||||
|         new Set( | ||||
|           icons | ||||
|             .filter((i) => (pack !== null ? i.package === pack : true)) | ||||
|             .map((i) => i.category) | ||||
|         ) | ||||
|       ).map((x) => | ||||
|         x === '' | ||||
|           ? { value: '', label: t`Uncategorized` } | ||||
|           : { value: x, label: x } | ||||
|       ), | ||||
|     [icons, pack] | ||||
|   ); | ||||
|   const packs = useMemo( | ||||
|     () => iconPacks.map((pack) => ({ value: pack.prefix, label: pack.name })), | ||||
|     [iconPacks] | ||||
|   ); | ||||
|  | ||||
|   const applyFilters = ( | ||||
|     iconList: RenderIconType[], | ||||
|     category: string | null, | ||||
|     pack: string | null | ||||
|   ) => { | ||||
|     if (category === null && pack === null) return iconList; | ||||
|     return iconList.filter( | ||||
|       (i) => | ||||
|         (category !== null ? i.category === category : true) && | ||||
|         (pack !== null ? i.package === pack : true) | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const filteredIcons = useMemo(() => { | ||||
|     if (!debouncedSearchValue) { | ||||
|       return applyFilters(icons, category, pack); | ||||
|     } | ||||
|  | ||||
|     const res = filter.search(debouncedSearchValue.trim()).map((r) => r.item); | ||||
|  | ||||
|     return applyFilters(res, category, pack); | ||||
|   }, [debouncedSearchValue, filter, category, pack]); | ||||
|  | ||||
|   // Reset category when pack changes and the current category is not available in the new pack | ||||
|   useEffect(() => { | ||||
|     if (value === null) return; | ||||
|  | ||||
|     if (!categories.find((c) => c.value === category)) { | ||||
|       setCategory(null); | ||||
|     } | ||||
|   }, [pack]); | ||||
|  | ||||
|   const { width, ref } = useElementSize(); | ||||
|  | ||||
|   return ( | ||||
|     <Stack gap={6} ref={ref}> | ||||
|       <Group gap={4}> | ||||
|         <TextInput | ||||
|           value={searchValue} | ||||
|           onChange={(e) => setSearchValue(e.currentTarget.value)} | ||||
|           placeholder={t`Search...`} | ||||
|           rightSection={ | ||||
|             searchValue && !definition.required ? ( | ||||
|               <IconX size="1rem" onClick={() => setSearchValue('')} /> | ||||
|             ) : null | ||||
|           } | ||||
|           flex={1} | ||||
|         /> | ||||
|         <Select | ||||
|           value={category} | ||||
|           onChange={(c) => startTransition(() => setCategory(c))} | ||||
|           data={categories} | ||||
|           comboboxProps={{ withinPortal: false }} | ||||
|           clearable | ||||
|           placeholder={t`Select category`} | ||||
|         /> | ||||
|  | ||||
|         <Select | ||||
|           value={pack} | ||||
|           onChange={(c) => startTransition(() => setPack(c))} | ||||
|           data={packs} | ||||
|           comboboxProps={{ withinPortal: false }} | ||||
|           clearable | ||||
|           placeholder={t`Select pack`} | ||||
|         /> | ||||
|       </Group> | ||||
|  | ||||
|       <Text size="sm" c="dimmed" ta="center" mt={-4}> | ||||
|         <Trans>{filteredIcons.length} icons</Trans> | ||||
|       </Text> | ||||
|  | ||||
|       <DropdownList | ||||
|         icons={filteredIcons} | ||||
|         onChange={onChange} | ||||
|         combobox={combobox} | ||||
|         value={value} | ||||
|         width={width} | ||||
|         open={open} | ||||
|       /> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function DropdownList({ | ||||
|   icons, | ||||
|   onChange, | ||||
|   combobox, | ||||
|   value, | ||||
|   width, | ||||
|   open | ||||
| }: Readonly<{ | ||||
|   icons: RenderIconType[]; | ||||
|   onChange: (newVal: string | null) => void; | ||||
|   combobox: ComboboxStore; | ||||
|   value: string | null; | ||||
|   width: number; | ||||
|   open: boolean; | ||||
| }>) { | ||||
|   // Get the inner width of the dropdown (excluding the scrollbar) by using the outerRef provided by the react-window Grid element | ||||
|   const { width: innerWidth, ref: innerRef } = useElementSize(); | ||||
|  | ||||
|   const columnCount = Math.floor(innerWidth / 35); | ||||
|   const rowCount = columnCount > 0 ? Math.ceil(icons.length / columnCount) : 0; | ||||
|  | ||||
|   const gridRef = useRef<Grid>(null); | ||||
|   const hasScrolledToPositionRef = useRef(true); | ||||
|  | ||||
|   // Reset the has already scrolled to position state when the dropdown open state is changed | ||||
|   useEffect(() => { | ||||
|     const timeoutId = setTimeout(() => { | ||||
|       hasScrolledToPositionRef.current = false; | ||||
|     }, 100); | ||||
|  | ||||
|     return () => clearTimeout(timeoutId); | ||||
|   }, [open]); | ||||
|  | ||||
|   // Scroll to the selected icon if not already has scrolled to position | ||||
|   useEffect(() => { | ||||
|     // Do not scroll if the value is not set, columnCount is not set, the dropdown is not open, or the position has already been scrolled to | ||||
|     if ( | ||||
|       !value || | ||||
|       columnCount === 0 || | ||||
|       hasScrolledToPositionRef.current || | ||||
|       !open | ||||
|     ) | ||||
|       return; | ||||
|  | ||||
|     const iconIdx = icons.findIndex((i) => i.name === value); | ||||
|     if (iconIdx === -1) return; | ||||
|  | ||||
|     gridRef.current?.scrollToItem({ | ||||
|       align: 'start', | ||||
|       rowIndex: Math.floor(iconIdx / columnCount) | ||||
|     }); | ||||
|     hasScrolledToPositionRef.current = true; | ||||
|   }, [value, columnCount, open]); | ||||
|  | ||||
|   return ( | ||||
|     <Grid | ||||
|       height={200} | ||||
|       width={width} | ||||
|       rowCount={rowCount} | ||||
|       columnCount={columnCount} | ||||
|       rowHeight={35} | ||||
|       columnWidth={35} | ||||
|       itemData={icons} | ||||
|       outerRef={innerRef} | ||||
|       ref={gridRef} | ||||
|     > | ||||
|       {({ columnIndex, rowIndex, data, style }) => { | ||||
|         const icon = data[rowIndex * columnCount + columnIndex]; | ||||
|  | ||||
|         // Grid has empty cells in the last row if the number of icons is not a multiple of columnCount | ||||
|         if (icon === undefined) return null; | ||||
|  | ||||
|         const isSelected = value === icon.name; | ||||
|  | ||||
|         return ( | ||||
|           <Box | ||||
|             key={icon.name} | ||||
|             title={icon.name} | ||||
|             onClick={() => { | ||||
|               onChange(isSelected ? null : icon.name); | ||||
|               combobox.closeDropdown(); | ||||
|             }} | ||||
|             style={{ | ||||
|               display: 'flex', | ||||
|               alignItems: 'center', | ||||
|               justifyContent: 'center', | ||||
|               cursor: 'pointer', | ||||
|               background: isSelected | ||||
|                 ? 'var(--mantine-color-blue-filled)' | ||||
|                 : 'unset', | ||||
|               borderRadius: 'var(--mantine-radius-default)', | ||||
|               ...style | ||||
|             }} | ||||
|           > | ||||
|             <ApiIcon name={icon.name} size={24} /> | ||||
|           </Box> | ||||
|         ); | ||||
|       }} | ||||
|     </Grid> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										13
									
								
								src/frontend/src/components/items/ApiIcon.css.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/frontend/src/components/items/ApiIcon.css.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { style } from '@vanilla-extract/css'; | ||||
|  | ||||
| export const icon = style({ | ||||
|   fontStyle: 'normal', | ||||
|   fontWeight: 'normal', | ||||
|   fontVariant: 'normal', | ||||
|   textTransform: 'none', | ||||
|   lineHeight: 1, | ||||
|   width: 'fit-content', | ||||
|   // Better font rendering | ||||
|   WebkitFontSmoothing: 'antialiased', | ||||
|   MozOsxFontSmoothing: 'grayscale' | ||||
| }); | ||||
							
								
								
									
										27
									
								
								src/frontend/src/components/items/ApiIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/frontend/src/components/items/ApiIcon.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { useIconState } from '../../states/IconState'; | ||||
| import * as classes from './ApiIcon.css'; | ||||
|  | ||||
| type ApiIconProps = { | ||||
|   name: string; | ||||
|   size?: number; | ||||
| }; | ||||
|  | ||||
| export const ApiIcon = ({ name: _name, size = 22 }: ApiIconProps) => { | ||||
|   const [iconPackage, name, variant] = _name.split(':'); | ||||
|   const icon = useIconState( | ||||
|     (s) => s.packagesMap[iconPackage]?.['icons'][name]?.['variants'][variant] | ||||
|   ); | ||||
|   const unicode = icon ? String.fromCodePoint(parseInt(icon, 16)) : ''; | ||||
|  | ||||
|   return ( | ||||
|     <i | ||||
|       className={classes.icon} | ||||
|       style={{ | ||||
|         fontFamily: `inventree-icon-font-${iconPackage}`, | ||||
|         fontSize: size | ||||
|       }} | ||||
|     > | ||||
|       {unicode} | ||||
|     </i> | ||||
|   ); | ||||
| }; | ||||
| @@ -14,6 +14,7 @@ import { identifierString } from '../../functions/conversion'; | ||||
| import { navigateToLink } from '../../functions/navigation'; | ||||
|  | ||||
| export type Breadcrumb = { | ||||
|   icon?: React.ReactNode; | ||||
|   name: string; | ||||
|   url: string; | ||||
| }; | ||||
| @@ -69,7 +70,10 @@ export function BreadcrumbList({ | ||||
|                   navigateToLink(breadcrumb.url, navigate, event) | ||||
|                 } | ||||
|               > | ||||
|                 <Text size="sm">{breadcrumb.name}</Text> | ||||
|                 <Group gap={4}> | ||||
|                   {breadcrumb.icon} | ||||
|                   <Text size="sm">{breadcrumb.name}</Text> | ||||
|                 </Group> | ||||
|               </Anchor> | ||||
|             ); | ||||
|           })} | ||||
|   | ||||
| @@ -15,7 +15,6 @@ import { | ||||
| import { | ||||
|   IconChevronDown, | ||||
|   IconChevronRight, | ||||
|   IconPoint, | ||||
|   IconSitemap | ||||
| } from '@tabler/icons-react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| @@ -28,6 +27,7 @@ import { ModelType } from '../../enums/ModelType'; | ||||
| import { navigateToLink } from '../../functions/navigation'; | ||||
| import { getDetailUrl } from '../../functions/urls'; | ||||
| import { apiUrl } from '../../states/ApiState'; | ||||
| import { ApiIcon } from '../items/ApiIcon'; | ||||
| import { StylishText } from '../items/StylishText'; | ||||
|  | ||||
| /* | ||||
| @@ -100,7 +100,12 @@ export default function NavigationTree({ | ||||
|       let node = { | ||||
|         ...query.data[ii], | ||||
|         children: [], | ||||
|         label: query.data[ii].name, | ||||
|         label: ( | ||||
|           <Group gap="xs"> | ||||
|             <ApiIcon name={query.data[ii].icon} /> | ||||
|             {query.data[ii].name} | ||||
|           </Group> | ||||
|         ), | ||||
|         value: query.data[ii].pk.toString(), | ||||
|         selected: query.data[ii].pk === selectedId | ||||
|       }; | ||||
| @@ -157,9 +162,7 @@ export default function NavigationTree({ | ||||
|               ) : ( | ||||
|                 <IconChevronRight /> | ||||
|               ) | ||||
|             ) : ( | ||||
|               <IconPoint /> | ||||
|             )} | ||||
|             ) : null} | ||||
|           </ActionIcon> | ||||
|           <Anchor | ||||
|             onClick={(event: any) => follow(payload.node, event)} | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { Breadcrumb, BreadcrumbList } from './BreadcrumbList'; | ||||
|  | ||||
| interface PageDetailInterface { | ||||
|   title?: string; | ||||
|   icon?: ReactNode; | ||||
|   subtitle?: string; | ||||
|   imageUrl?: string; | ||||
|   detail?: ReactNode; | ||||
| @@ -24,6 +25,7 @@ interface PageDetailInterface { | ||||
|  */ | ||||
| export function PageDetail({ | ||||
|   title, | ||||
|   icon, | ||||
|   subtitle, | ||||
|   detail, | ||||
|   badges, | ||||
| @@ -50,9 +52,12 @@ export function PageDetail({ | ||||
|               <Stack gap="xs"> | ||||
|                 {title && <StylishText size="lg">{title}</StylishText>} | ||||
|                 {subtitle && ( | ||||
|                   <Text size="md" truncate> | ||||
|                     {subtitle} | ||||
|                   </Text> | ||||
|                   <Group gap="xs"> | ||||
|                     {icon} | ||||
|                     <Text size="md" truncate> | ||||
|                       {subtitle} | ||||
|                     </Text> | ||||
|                   </Group> | ||||
|                 )} | ||||
|               </Stack> | ||||
|             </Group> | ||||
|   | ||||
| @@ -151,6 +151,7 @@ export function RenderRemoteInstance({ | ||||
| export function RenderInlineModel({ | ||||
|   primary, | ||||
|   secondary, | ||||
|   prefix, | ||||
|   suffix, | ||||
|   image, | ||||
|   labels, | ||||
| @@ -161,6 +162,7 @@ export function RenderInlineModel({ | ||||
|   primary: string; | ||||
|   secondary?: string; | ||||
|   showSecondary?: boolean; | ||||
|   prefix?: ReactNode; | ||||
|   suffix?: ReactNode; | ||||
|   image?: string; | ||||
|   labels?: string[]; | ||||
| @@ -181,6 +183,7 @@ export function RenderInlineModel({ | ||||
|   return ( | ||||
|     <Group gap="xs" justify="space-between" wrap="nowrap"> | ||||
|       <Group gap="xs" justify="left" wrap="nowrap"> | ||||
|         {prefix} | ||||
|         {image && <Thumbnail src={image} size={18} />} | ||||
|         {url ? ( | ||||
|           <Anchor href={url} onClick={(event: any) => onClick(event)}> | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; | ||||
|  | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { getDetailUrl } from '../../functions/urls'; | ||||
| import { ApiIcon } from '../items/ApiIcon'; | ||||
| import { InstanceRenderInterface, RenderInlineModel } from './Instance'; | ||||
|  | ||||
| /** | ||||
| @@ -60,6 +61,7 @@ export function RenderPartCategory( | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       {...props} | ||||
|       prefix={instance.icon && <ApiIcon name={instance.icon} />} | ||||
|       primary={`${lvl} ${instance.name}`} | ||||
|       secondary={instance.description} | ||||
|       url={ | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { ReactNode } from 'react'; | ||||
|  | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { getDetailUrl } from '../../functions/urls'; | ||||
| import { ApiIcon } from '../items/ApiIcon'; | ||||
| import { InstanceRenderInterface, RenderInlineModel } from './Instance'; | ||||
|  | ||||
| /** | ||||
| @@ -16,6 +17,7 @@ export function RenderStockLocation( | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       {...props} | ||||
|       prefix={instance.icon && <ApiIcon name={instance.icon} />} | ||||
|       primary={instance.name} | ||||
|       secondary={instance.description} | ||||
|       url={ | ||||
| @@ -36,7 +38,7 @@ export function RenderStockLocationType({ | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={instance.name} | ||||
|       // TODO: render location icon here too (ref: #7237) | ||||
|       prefix={instance.icon && <ApiIcon name={instance.icon} />} | ||||
|       secondary={instance.description + ` (${instance.location_count})`} | ||||
|     /> | ||||
|   ); | ||||
|   | ||||
| @@ -47,6 +47,7 @@ export enum ApiEndpoints { | ||||
|   sso_providers = 'auth/providers/', | ||||
|   group_list = 'user/group/', | ||||
|   owner_list = 'user/owner/', | ||||
|   icons = 'icons/', | ||||
|  | ||||
|   // Data import endpoints | ||||
|   import_session_list = 'importer/session/', | ||||
|   | ||||
| @@ -132,7 +132,9 @@ export function partCategoryFields(): ApiFormFieldSet { | ||||
|     }, | ||||
|     default_keywords: {}, | ||||
|     structural: {}, | ||||
|     icon: {} | ||||
|     icon: { | ||||
|       field_type: 'icon' | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return fields; | ||||
|   | ||||
| @@ -909,7 +909,9 @@ export function stockLocationFields(): ApiFormFieldSet { | ||||
|     description: {}, | ||||
|     structural: {}, | ||||
|     external: {}, | ||||
|     custom_icon: {}, | ||||
|     custom_icon: { | ||||
|       field_type: 'icon' | ||||
|     }, | ||||
|     location_type: {} | ||||
|   }; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core'; | ||||
| import { Group, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconCategory, | ||||
|   IconDots, | ||||
| @@ -18,6 +18,7 @@ import { | ||||
|   DeleteItemAction, | ||||
|   EditItemAction | ||||
| } from '../../components/items/ActionDropdown'; | ||||
| import { ApiIcon } from '../../components/items/ApiIcon'; | ||||
| import InstanceDetail from '../../components/nav/InstanceDetail'; | ||||
| import NavigationTree from '../../components/nav/NavigationTree'; | ||||
| import { PageDetail } from '../../components/nav/PageDetail'; | ||||
| @@ -78,7 +79,13 @@ export default function CategoryDetail() { | ||||
|         type: 'text', | ||||
|         name: 'name', | ||||
|         label: t`Name`, | ||||
|         copy: true | ||||
|         copy: true, | ||||
|         value_formatter: () => ( | ||||
|           <Group gap="xs"> | ||||
|             {category.icon && <ApiIcon name={category.icon} />} | ||||
|             {category.name} | ||||
|           </Group> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         type: 'text', | ||||
| @@ -267,7 +274,8 @@ export default function CategoryDetail() { | ||||
|       { name: t`Parts`, url: '/part' }, | ||||
|       ...(category.path ?? []).map((c: any) => ({ | ||||
|         name: c.name, | ||||
|         url: getDetailUrl(ModelType.partcategory, c.pk) | ||||
|         url: getDetailUrl(ModelType.partcategory, c.pk), | ||||
|         icon: c.icon ? <ApiIcon name={c.icon} /> : undefined | ||||
|       })) | ||||
|     ], | ||||
|     [category] | ||||
| @@ -296,6 +304,7 @@ export default function CategoryDetail() { | ||||
|           <PageDetail | ||||
|             title={t`Part Category`} | ||||
|             subtitle={category?.name} | ||||
|             icon={category?.icon && <ApiIcon name={category?.icon} />} | ||||
|             breadcrumbs={breadcrumbs} | ||||
|             breadcrumbAction={() => { | ||||
|               setTreeOpen(true); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Skeleton, Stack, Text } from '@mantine/core'; | ||||
| import { Group, Skeleton, Stack, Text } from '@mantine/core'; | ||||
| import { | ||||
|   IconDots, | ||||
|   IconInfoCircle, | ||||
| @@ -23,6 +23,7 @@ import { | ||||
|   UnlinkBarcodeAction, | ||||
|   ViewBarcodeAction | ||||
| } from '../../components/items/ActionDropdown'; | ||||
| import { ApiIcon } from '../../components/items/ApiIcon'; | ||||
| import InstanceDetail from '../../components/nav/InstanceDetail'; | ||||
| import NavigationTree from '../../components/nav/NavigationTree'; | ||||
| import { PageDetail } from '../../components/nav/PageDetail'; | ||||
| @@ -85,7 +86,13 @@ export default function Stock() { | ||||
|         type: 'text', | ||||
|         name: 'name', | ||||
|         label: t`Name`, | ||||
|         copy: true | ||||
|         copy: true, | ||||
|         value_formatter: () => ( | ||||
|           <Group gap="xs"> | ||||
|             {location.icon && <ApiIcon name={location.icon} />} | ||||
|             {location.name} | ||||
|           </Group> | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         type: 'text', | ||||
| @@ -352,7 +359,8 @@ export default function Stock() { | ||||
|       { name: t`Stock`, url: '/stock' }, | ||||
|       ...(location.path ?? []).map((l: any) => ({ | ||||
|         name: l.name, | ||||
|         url: getDetailUrl(ModelType.stocklocation, l.pk) | ||||
|         url: getDetailUrl(ModelType.stocklocation, l.pk), | ||||
|         icon: l.icon ? <ApiIcon name={l.icon} /> : undefined | ||||
|       })) | ||||
|     ], | ||||
|     [location] | ||||
| @@ -378,6 +386,7 @@ export default function Stock() { | ||||
|           <PageDetail | ||||
|             title={t`Stock Items`} | ||||
|             subtitle={location?.name} | ||||
|             icon={location?.icon && <ApiIcon name={location?.icon} />} | ||||
|             actions={locationActions} | ||||
|             breadcrumbs={breadcrumbs} | ||||
|             breadcrumbAction={() => { | ||||
|   | ||||
							
								
								
									
										68
									
								
								src/frontend/src/states/IconState.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/frontend/src/states/IconState.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| import { create } from 'zustand'; | ||||
|  | ||||
| import { api } from '../App'; | ||||
| import { ApiEndpoints } from '../enums/ApiEndpoints'; | ||||
| import { apiUrl } from './ApiState'; | ||||
| import { useLocalState } from './LocalState'; | ||||
|  | ||||
| type IconPackage = { | ||||
|   name: string; | ||||
|   prefix: string; | ||||
|   fonts: Record<string, string>; | ||||
|   icons: Record< | ||||
|     string, | ||||
|     { | ||||
|       name: string; | ||||
|       category: string; | ||||
|       tags: string[]; | ||||
|       variants: Record<string, string>; | ||||
|     } | ||||
|   >; | ||||
| }; | ||||
|  | ||||
| type IconState = { | ||||
|   hasLoaded: boolean; | ||||
|   packages: IconPackage[]; | ||||
|   packagesMap: Record<string, IconPackage>; | ||||
|   fetchIcons: () => Promise<void>; | ||||
| }; | ||||
|  | ||||
| export const useIconState = create<IconState>()((set, get) => ({ | ||||
|   hasLoaded: false, | ||||
|   packages: [], | ||||
|   packagesMap: {}, | ||||
|   fetchIcons: async () => { | ||||
|     if (get().hasLoaded) return; | ||||
|  | ||||
|     const host = useLocalState.getState().host; | ||||
|  | ||||
|     const packs = await api.get(apiUrl(ApiEndpoints.icons)); | ||||
|  | ||||
|     await Promise.all( | ||||
|       packs.data.map(async (pack: any) => { | ||||
|         const fontName = `inventree-icon-font-${pack.prefix}`; | ||||
|         const src = Object.entries(pack.fonts as Record<string, string>) | ||||
|           .map( | ||||
|             ([format, url]) => | ||||
|               `url(${ | ||||
|                 url.startsWith('/') ? host + url : url | ||||
|               }) format("${format}")` | ||||
|           ) | ||||
|           .join(',\n'); | ||||
|         const font = new FontFace(fontName, src + ';'); | ||||
|         await font.load(); | ||||
|         document.fonts.add(font); | ||||
|  | ||||
|         return font; | ||||
|       }) | ||||
|     ); | ||||
|  | ||||
|     set({ | ||||
|       hasLoaded: true, | ||||
|       packages: packs.data, | ||||
|       packagesMap: Object.fromEntries( | ||||
|         packs.data.map((pack: any) => [pack.prefix, pack]) | ||||
|       ) | ||||
|     }); | ||||
|   } | ||||
| })); | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { setApiDefaults } from '../App'; | ||||
| import { useServerApiState } from './ApiState'; | ||||
| import { useIconState } from './IconState'; | ||||
| import { useGlobalSettingsState, useUserSettingsState } from './SettingsState'; | ||||
| import { useGlobalStatusState } from './StatusState'; | ||||
| import { useUserState } from './UserState'; | ||||
| @@ -138,4 +139,5 @@ export function fetchGlobalStates() { | ||||
|   useUserSettingsState.getState().fetchSettings(); | ||||
|   useGlobalSettingsState.getState().fetchSettings(); | ||||
|   useGlobalStatusState.getState().fetchStatus(); | ||||
|   useIconState.getState().fetchIcons(); | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Group } from '@mantine/core'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||
| import { YesNoButton } from '../../components/buttons/YesNoButton'; | ||||
| import { ApiIcon } from '../../components/items/ApiIcon'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| @@ -32,7 +34,13 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) { | ||||
|       { | ||||
|         accessor: 'name', | ||||
|         sortable: true, | ||||
|         switchable: false | ||||
|         switchable: false, | ||||
|         render: (record: any) => ( | ||||
|           <Group gap="xs"> | ||||
|             {record.icon && <ApiIcon name={record.icon} />} | ||||
|             {record.name} | ||||
|           </Group> | ||||
|         ) | ||||
|       }, | ||||
|       DescriptionColumn({}), | ||||
|       { | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||
| import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; | ||||
| import { ApiIcon } from '../../components/items/ApiIcon'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| import { | ||||
| @@ -25,7 +26,9 @@ export default function LocationTypesTable() { | ||||
|     return { | ||||
|       name: {}, | ||||
|       description: {}, | ||||
|       icon: {} | ||||
|       icon: { | ||||
|         field_type: 'icon' | ||||
|       } | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
| @@ -55,6 +58,12 @@ export default function LocationTypesTable() { | ||||
|  | ||||
|   const tableColumns: TableColumn[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
|         accessor: 'icon', | ||||
|         title: t`Icon`, | ||||
|         sortable: true, | ||||
|         render: (value: any) => <ApiIcon name={value.icon} /> | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'name', | ||||
|         title: t`Name`, | ||||
| @@ -64,11 +73,6 @@ export default function LocationTypesTable() { | ||||
|         accessor: 'description', | ||||
|         title: t`Description` | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'icon', | ||||
|         title: t`Icon`, | ||||
|         sortable: true | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'location_count', | ||||
|         sortable: true | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Group } from '@mantine/core'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||
| import { ApiIcon } from '../../components/items/ApiIcon'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { ModelType } from '../../enums/ModelType'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| @@ -69,7 +71,13 @@ export function StockLocationTable({ parentId }: { parentId?: any }) { | ||||
|     return [ | ||||
|       { | ||||
|         accessor: 'name', | ||||
|         switchable: false | ||||
|         switchable: false, | ||||
|         render: (record: any) => ( | ||||
|           <Group gap="xs"> | ||||
|             {record.icon && <ApiIcon name={record.icon} />} | ||||
|             {record.name} | ||||
|           </Group> | ||||
|         ) | ||||
|       }, | ||||
|       DescriptionColumn({}), | ||||
|       { | ||||
|   | ||||
| @@ -335,6 +335,13 @@ | ||||
|     "@babel/plugin-transform-modules-commonjs" "^7.24.7" | ||||
|     "@babel/plugin-transform-typescript" "^7.24.7" | ||||
|  | ||||
| "@babel/runtime@^7.0.0": | ||||
|   version "7.24.8" | ||||
|   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" | ||||
|   integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== | ||||
|   dependencies: | ||||
|     regenerator-runtime "^0.14.0" | ||||
|  | ||||
| "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": | ||||
|   version "7.24.6" | ||||
|   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e" | ||||
| @@ -2634,6 +2641,13 @@ | ||||
|   dependencies: | ||||
|     "@types/react" "*" | ||||
|  | ||||
| "@types/react-window@^1.8.8": | ||||
|   version "1.8.8" | ||||
|   resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" | ||||
|   integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== | ||||
|   dependencies: | ||||
|     "@types/react" "*" | ||||
|  | ||||
| "@types/react@*", "@types/react@^18.3.3": | ||||
|   version "18.3.3" | ||||
|   resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" | ||||
| @@ -3845,6 +3859,11 @@ function-bind@^1.1.2: | ||||
|   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" | ||||
|   integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== | ||||
|  | ||||
| fuse.js@^7.0.0: | ||||
|   version "7.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2" | ||||
|   integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q== | ||||
|  | ||||
| gensync@^1.0.0-beta.2: | ||||
|   version "1.0.0-beta.2" | ||||
|   resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" | ||||
| @@ -4538,6 +4557,11 @@ media-query-parser@^2.0.2: | ||||
|   dependencies: | ||||
|     "@babel/runtime" "^7.12.5" | ||||
|  | ||||
| "memoize-one@>=3.1.1 <6": | ||||
|   version "5.2.1" | ||||
|   resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" | ||||
|   integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== | ||||
|  | ||||
| memoize-one@^6.0.0: | ||||
|   version "6.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" | ||||
| @@ -5511,6 +5535,14 @@ react-transition-group@4.4.5, react-transition-group@^4.3.0, react-transition-gr | ||||
|     loose-envify "^1.4.0" | ||||
|     prop-types "^15.6.2" | ||||
|  | ||||
| react-window@^1.8.10: | ||||
|   version "1.8.10" | ||||
|   resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" | ||||
|   integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== | ||||
|   dependencies: | ||||
|     "@babel/runtime" "^7.0.0" | ||||
|     memoize-one ">=3.1.1 <6" | ||||
|  | ||||
| react@^18.2.0, react@^18.3.1: | ||||
|   version "18.3.1" | ||||
|   resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user