2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-27 09:10:51 +00:00

Migrate Icons to Tabler icons and integrate into PUI ()

* 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:
Lukas
2024-07-24 04:36:02 +02:00
committed by GitHub
parent d5afc37264
commit 96abd0898c
75 changed files with 1702 additions and 100 deletions

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

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

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

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

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