mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-14 06:31:27 +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