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:
Lukas 2024-07-24 04:36:02 +02:00 committed by GitHub
parent d5afc37264
commit 96abd0898c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 1702 additions and 100 deletions

26
.vscode/launch.json vendored
View File

@ -6,19 +6,37 @@
"configurations": [
{
"name": "InvenTree Server",
"type": "python",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": ["runserver"],
"args": [
"runserver",
// "0.0.0.0:8000", // expose server in network (useful for testing with mobile app)
// "--noreload" // disable auto-reload
],
"django": true,
"justMyCode": true
},
{
"name": "InvenTree Server - Tests",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": [
"test",
// "part.test_api.PartCategoryAPITest", // run only a specific test
],
"django": true,
"justMyCode": true
},
{
"name": "InvenTree Server - 3rd party",
"type": "python",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
"args": ["runserver"],
"args": [
"runserver"
],
"django": true,
"justMyCode": false
},

View File

@ -0,0 +1,19 @@
---
title: Icon Pack Mixin
---
## IconPackMixin
The IconPackMixin class provides basic functionality for letting plugins expose custom icon packs that are available in the InvenTree UI. This is especially useful to provide a custom crafted icon pack with icons for different location types, e.g. different sizes and styles of drawers, bags, ESD bags, ... which are not available in the standard tabler icons library.
### Sample Plugin
The following example demonstrates how to use the `IconPackMixin` class to add a custom icon pack:
::: plugin.samples.icons.icon_sample.SampleIconPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -259,6 +259,31 @@ A shortcut function is provided for rendering an image associated with a Company
*Preview* and *thumbnail* image variations can be rendered for the `company_image` tag, in a similar manner to [part image variations](#image-variations)
## Icons
Some models (e.g. part categories and locations) allow to specify a custom icon. To render these icons in a report, there is a `{% raw %}{% icon location.icon %}{% endraw %}` template tag from the report template library available.
This tag renders the required html for the icon.
!!! info "Loading fonts"
Additionally the icon fonts need to be loaded into the template. This can be done using the `{% raw %}{% include_icon_fonts %}{% endraw %}` template tag inside of a style block
!!! tip "Custom classes for styling the icon further"
The icon template tag accepts an optional `class` argument which can be used to apply a custom class to the rendered icon used to style the icon further e.g. positioning it, changing it's size, ... `{% raw %}{% icon location.icon class="my-class" %}{% endraw %}`.
```html
{% raw %}
{% load report %}
{% block style %}
{% include_icon_fonts %}
{% endblock style %}
{% icon location.icon %}
{% endraw %}
```
## InvenTree Logo
A template tag is provided to load the InvenTree logo image into a report. You can render the logo using the `{% raw %}{% logo_image %}{% endraw %}` tag:

View File

@ -6,6 +6,14 @@ title: Stock
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
### Icons
Stock locations can be assigned custom icons (either directly or through [Stock Location Types](#stock-location-type)). When using PUI there is a custom icon picker component available that can help to select the right icon. However in CUI the icon needs to be entered manually.
By default, the tabler icons package (with prefix: `ti`) is available. To manually select an item, search on the [tabler icons](https://tabler.io/icons) page for an icon and copy its name e.g. `bookmark`. Some icons have a filled and an outline version (if no variants are specified, it's an outline variant). Now these values can be put into the format: `<package-prefix>:<icon-name>:<variant>`. E.g. `ti:bookmark:outline` or `ti:bookmark:filled`.
If there are some icons missing in the tabler icons package, users can even install their own custom icon packs through a plugin. See [`IconPackMixin`](../extend/plugins/icon.md).
## Stock Location Type
A stock location type represents a specific type of location (e.g. one specific size of drawer, shelf, ... or box) which can be assigned to multiple stock locations. In the first place, it is used to specify an icon and having the icon in sync for all locations that use this location type, but it also serves as a data field to quickly see what type of location this is. It is planned to add e.g. drawer dimension information to the location type to add a "find a matching, empty stock location" tool.

View File

@ -203,6 +203,7 @@ nav:
- Barcode Mixin: extend/plugins/barcode.md
- Currency Mixin: extend/plugins/currency.md
- Event Mixin: extend/plugins/event.md
- Icon Pack Mixin: extend/plugins/icon.md
- Label Printing Mixin: extend/plugins/label.md
- Locate Mixin: extend/plugins/locate.md
- Navigation Mixin: extend/plugins/navigation.md

View File

@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 227
INVENTREE_API_VERSION = 228
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v228 - 2024-07-18 : https://github.com/inventree/InvenTree/pull/7684
- Adds "icon" field to the PartCategory.path and StockLocation.path API
- Adds icon packages API endpoint
v227 - 2024-07-19 : https://github.com/inventree/InvenTree/pull/7693/
- Adds endpoints to list and revoke the tokens issued to the current user

View File

@ -0,0 +1,192 @@
"""Custom management command to migrate the old FontAwesome icons."""
import json
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from django.db import models
from common.icons import validate_icon
from part.models import PartCategory
from stock.models import StockLocation, StockLocationType
class Command(BaseCommand):
"""Generate an icon map from the FontAwesome library to the new icon library."""
help = """Helper command to migrate the old FontAwesome icons to the new icon library."""
def add_arguments(self, parser):
"""Add the arguments."""
parser.add_argument(
'--output-file',
type=str,
help='Path to file to write generated icon map to',
)
parser.add_argument(
'--input-file', type=str, help='Path to file to read icon map from'
)
parser.add_argument(
'--include-items',
default=False,
action='store_true',
help='Include referenced inventree items in the output icon map (optional)',
)
parser.add_argument(
'--import-now',
default=False,
action='store_true',
help='CAUTION: If this flag is set, the icon map will be imported and the database will be touched',
)
def handle(self, *args, **kwargs):
"""Generate an icon map from the FontAwesome library to the new icon library."""
# Check for invalid combinations of arguments
if kwargs['output_file'] and kwargs['input_file']:
raise CommandError('Cannot specify both --input-file and --output-file')
if not kwargs['output_file'] and not kwargs['input_file']:
raise CommandError('Must specify either --input-file or --output-file')
if kwargs['include_items'] and not kwargs['output_file']:
raise CommandError(
'--include-items can only be used with an --output-file specified'
)
if kwargs['output_file'] and kwargs['import_now']:
raise CommandError(
'--import-now can only be used with an --input-file specified'
)
ICON_MODELS = [
(StockLocation, 'custom_icon'),
(StockLocationType, 'icon'),
(PartCategory, '_icon'),
]
def get_model_items_with_icons(model: models.Model, icon_field: str):
"""Return a list of models with icon fields."""
return model.objects.exclude(**{f'{icon_field}__isnull': True}).exclude(**{
f'{icon_field}__exact': ''
})
# Generate output icon map file
if kwargs['output_file']:
icons = {}
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
if icon not in icons:
icons[icon] = {
**({'items': []} if kwargs['include_items'] else {}),
'new_icon': '',
}
if kwargs['include_items']:
icons[icon]['items'].append({
'model': model.__name__.lower(),
'id': item.id, # type: ignore
})
self.stdout.write(f'Writing icon map for {len(icons.keys())} icons')
with open(kwargs['output_file'], 'w') as f:
json.dump(icons, f, indent=2)
self.stdout.write(f'Icon map written to {kwargs["output_file"]}')
# Import icon map file
if kwargs['input_file']:
with open(kwargs['input_file'], 'r') as f:
icons = json.load(f)
self.stdout.write(f'Loaded icon map for {len(icons.keys())} icons')
self.stdout.write('Verifying icon map')
has_errors = False
# Verify that all new icons are valid icons
for old_icon, data in icons.items():
try:
validate_icon(data.get('new_icon', ''))
except ValidationError:
self.stdout.write(
f'[ERR] Invalid icon: "{old_icon}" -> "{data.get("new_icon", "")}'
)
has_errors = True
# Verify that all required items are provided in the icon map
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
if icon not in icons:
self.stdout.write(
f' [ERR] Icon "{icon}" not found in icon map'
)
has_errors = True
# If there are errors, stop here
if has_errors:
self.stdout.write(
'[ERR] Icon map has errors, please fix them before continuing with importing'
)
return
# Import the icon map into the database if the flag is set
if kwargs['import_now']:
self.stdout.write('Start importing icons and updating database...')
cnt = 0
for model, icon_name in ICON_MODELS:
self.stdout.write(
f'Processing model {model.__name__} with icon field {icon_name}'
)
items = get_model_items_with_icons(model, icon_name)
for item in items:
icon = getattr(item, icon_name)
try:
validate_icon(icon)
continue # Skip if the icon is already valid
except ValidationError:
pass
setattr(item, icon_name, icons[icon]['new_icon'])
cnt += 1
item.save()
self.stdout.write(
f'Icon map successfully imported - changed {cnt} items'
)
self.stdout.write('Icons are now migrated')
else:
self.stdout.write('Icon map is valid and ready to be imported')
self.stdout.write(
'Run the command with --import-now to import the icon map and update the database'
)

View File

@ -575,6 +575,9 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
# e.g. for StockLocation, this value is 'location'
ITEM_PARENT_KEY = None
# Extra fields to include in the get_path result. E.g. icon
EXTRA_PATH_FIELDS = []
class Meta:
"""Metaclass defines extra model properties."""
@ -868,7 +871,14 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
name: <name>,
}
"""
return [{'pk': item.pk, 'name': item.name} for item in self.path]
return [
{
'pk': item.pk,
'name': item.name,
**{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS},
}
for item in self.path
]
def __str__(self):
"""String representation of a category is the full path to that category."""

View File

@ -1101,3 +1101,14 @@ a {
.large-treeview-icon {
font-size: 1em;
}
.api-icon {
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better font rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2024 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@ -1015,7 +1015,7 @@ class BuildOverallocationTest(BuildAPITest):
'accept_overallocated': 'trim',
},
expected_code=201,
max_query_count=550, # TODO: Come back and refactor this
max_query_count=555, # TODO: Come back and refactor this
)
self.build.refresh_from_db()

View File

@ -9,6 +9,7 @@ from django.http.response import HttpResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import csrf_exempt
import django_q.models
@ -25,6 +26,7 @@ from rest_framework.views import APIView
import common.models
import common.serializers
from common.icons import get_icon_packs
from common.settings import get_global_setting
from generic.states.api import AllStatusViews, StatusView
from importer.mixins import DataExportViewMixin
@ -743,6 +745,18 @@ class AttachmentDetail(RetrieveUpdateDestroyAPI):
return super().destroy(request, *args, **kwargs)
@method_decorator(cache_control(public=True, max_age=86400), name='dispatch')
class IconList(ListAPI):
"""List view for available icon packages."""
serializer_class = common.serializers.IconPackageSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
"""Return a list of all available icon packages."""
return get_icon_packs().values()
settings_api_urls = [
# User settings
path(
@ -957,6 +971,8 @@ common_api_urls = [
path('', ContentTypeList.as_view(), name='api-contenttype-list'),
]),
),
# Icons
path('icons/', IconList.as_view(), name='api-icon-list'),
]
admin_api_urls = [

View File

@ -0,0 +1,114 @@
"""Icon utilities for InvenTree."""
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from django.core.exceptions import ValidationError
from django.templatetags.static import static
logger = logging.getLogger('inventree')
_icon_packs = None
class Icon(TypedDict):
"""Dict type for an icon.
Attributes:
name: The name of the icon.
category: The category of the icon.
tags: A list of tags for the icon (used for search).
variants: A dictionary of variants for the icon, where the key is the variant name and the value is the variant's unicode hex character.
"""
name: str
category: str
tags: list[str]
variants: dict[str, str]
@dataclass
class IconPack:
"""Dataclass for an icon pack.
Attributes:
name: The name of the icon pack.
prefix: The prefix used for the icon pack.
fonts: A dictionary of different font file formats for the icon pack, where the key is the css format and the value a path to the font file.
icons: A dictionary of icons in the icon pack, where the key is the icon name and the value is a dictionary of the icon's variants.
"""
name: str
prefix: str
fonts: dict[str, str]
icons: dict[str, Icon]
def get_icon_packs():
"""Return a dictionary of available icon packs including their icons."""
global _icon_packs
if _icon_packs is None:
tabler_icons_path = Path(__file__).parent.parent.joinpath(
'InvenTree/static/tabler-icons/icons.json'
)
with open(tabler_icons_path, 'r') as tabler_icons_file:
tabler_icons = json.load(tabler_icons_file)
icon_packs = [
IconPack(
name='Tabler Icons',
prefix='ti',
fonts={
'woff2': static('tabler-icons/tabler-icons.woff2'),
'woff': static('tabler-icons/tabler-icons.woff'),
'truetype': static('tabler-icons/tabler-icons.ttf'),
},
icons=tabler_icons,
)
]
from plugin import registry
for plugin in registry.with_mixin('icon_pack', active=True):
try:
icon_packs.extend(plugin.icon_packs())
except Exception as e:
logger.warning('Error loading icon pack from plugin %s: %s', plugin, e)
_icon_packs = {pack.prefix: pack for pack in icon_packs}
return _icon_packs
def reload_icon_packs():
"""Reload the icon packs."""
global _icon_packs
_icon_packs = None
get_icon_packs()
def validate_icon(icon: str):
"""Validate an icon string in the format pack:name:variant."""
try:
pack, name, variant = icon.split(':')
except ValueError:
raise ValidationError(
f'Invalid icon format: {icon}, expected: pack:name:variant'
)
packs = get_icon_packs()
if pack not in packs:
raise ValidationError(f'Invalid icon pack: {pack}')
if name not in packs[pack].icons:
raise ValidationError(f'Invalid icon name: {name}')
if variant not in packs[pack].icons[name]['variants']:
raise ValidationError(f'Invalid icon variant: {variant}')
return packs[pack], packs[pack].icons[name], variant

View File

@ -1558,6 +1558,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Part Category Default Icon'),
'description': _('Part category default icon (empty means no icon)'),
'default': '',
'validator': common.validators.validate_icon,
},
'PART_PARAMETER_ENFORCE_UNITS': {
'name': _('Enforce Parameter Units'),
@ -1779,6 +1780,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Stock Location Default Icon'),
'description': _('Stock location default icon (empty means no icon)'),
'default': '',
'validator': common.validators.validate_icon,
},
'STOCK_SHOW_INSTALLED_ITEMS': {
'name': _('Show Installed Stock Items'),

View File

@ -565,3 +565,21 @@ class AttachmentSerializer(InvenTreeModelSerializer):
)
return super().save()
class IconSerializer(serializers.Serializer):
"""Serializer for an icon."""
name = serializers.CharField()
category = serializers.CharField()
tags = serializers.ListField(child=serializers.CharField())
variants = serializers.DictField(child=serializers.CharField())
class IconPackageSerializer(serializers.Serializer):
"""Serializer for a list of icons."""
name = serializers.CharField()
prefix = serializers.CharField()
fonts = serializers.DictField(child=serializers.CharField())
icons = serializers.DictField(child=IconSerializer())

View File

@ -20,6 +20,7 @@ from django.urls import reverse
import PIL
import common.validators
from common.settings import get_global_setting, set_global_setting
from InvenTree.helpers import str2bool
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin
@ -1524,3 +1525,44 @@ class ContentTypeAPITest(InvenTreeAPITestCase):
reverse('api-contenttype-detail-modelname', kwargs={'model': None}),
expected_code=404,
)
class IconAPITest(InvenTreeAPITestCase):
"""Unit tests for the Icons API."""
def test_list(self):
"""Test API list functionality."""
response = self.get(reverse('api-icon-list'), expected_code=200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['prefix'], 'ti')
self.assertEqual(response.data[0]['name'], 'Tabler Icons')
for font_format in ['woff2', 'woff', 'truetype']:
self.assertIn(font_format, response.data[0]['fonts'])
self.assertGreater(len(response.data[0]['icons']), 1000)
class ValidatorsTest(TestCase):
"""Unit tests for the custom validators."""
def test_validate_icon(self):
"""Test the validate_icon function."""
common.validators.validate_icon('')
common.validators.validate_icon(None)
with self.assertRaises(ValidationError):
common.validators.validate_icon('invalid')
with self.assertRaises(ValidationError):
common.validators.validate_icon('my:package:non-existing')
with self.assertRaises(ValidationError):
common.validators.validate_icon(
'ti:my-non-existing-icon:non-existing-variant'
)
with self.assertRaises(ValidationError):
common.validators.validate_icon('ti:package:non-existing-variant')
common.validators.validate_icon('ti:package:outline')

View File

@ -1,10 +1,12 @@
"""Validation helpers for common models."""
import re
from typing import Union
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import common.icons
from common.settings import get_global_setting
@ -103,3 +105,11 @@ def validate_email_domains(setting):
raise ValidationError(_('An empty domain is not allowed.'))
if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain):
raise ValidationError(_(f'Invalid domain name: {domain}'))
def validate_icon(name: Union[str, None]):
"""Validate the provided icon name, and ignore if empty."""
if name == '' or name is None:
return
common.icons.validate_icon(name)

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.11 on 2024-07-20 22:30
import common.icons
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0126_part_revision_of'),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.RenameField(
model_name='partcategory',
old_name='icon',
new_name='_icon',
),
migrations.AlterField(
model_name='partcategory',
name='_icon',
field=models.CharField(blank=True, db_column='icon', help_text='Icon (optional)', max_length=100, validators=[common.icons.validate_icon], verbose_name='Icon'),
),
],
),
]

View File

@ -50,6 +50,7 @@ import users.models
from build import models as BuildModels
from build.status_codes import BuildStatusGroups
from common.currency import currency_code_default
from common.icons import validate_icon
from common.models import InvenTreeSetting
from common.settings import get_global_setting, set_global_setting
from company.models import SupplierPart
@ -80,6 +81,8 @@ class PartCategory(InvenTree.models.InvenTreeTree):
ITEM_PARENT_KEY = 'category'
EXTRA_PATH_FIELDS = ['icon']
class Meta:
"""Metaclass defines extra model properties."""
@ -123,13 +126,37 @@ class PartCategory(InvenTree.models.InvenTreeTree):
help_text=_('Default keywords for parts in this category'),
)
icon = models.CharField(
_icon = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Icon'),
help_text=_('Icon (optional)'),
validators=[validate_icon],
db_column='icon',
)
@property
def icon(self):
"""Return the icon associated with this PartCategory or the default icon."""
if self._icon:
return self._icon
if default_icon := get_global_setting('PART_CATEGORY_DEFAULT_ICON', cache=True):
return default_icon
return ''
@icon.setter
def icon(self, value):
"""Setter for icon field."""
default_icon = get_global_setting('PART_CATEGORY_DEFAULT_ICON', cache=True)
# if icon is not defined previously and new value is default icon, do not save it
if not self._icon and value == default_icon:
return
self._icon = value
@staticmethod
def get_api_url():
"""Return the API url associated with the PartCategory model."""

View File

@ -139,6 +139,10 @@ class CategorySerializer(
child=serializers.DictField(), source='get_path', read_only=True
)
icon = serializers.CharField(
required=False, allow_blank=True, help_text=_('Icon (optional)'), max_length=100
)
parent_default_location = serializers.IntegerField(read_only=True)
@ -153,6 +157,10 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True)
icon = serializers.CharField(
required=False, allow_blank=True, help_text=_('Icon (optional)'), max_length=100
)
@staticmethod
def annotate_queryset(queryset):
"""Annotate the queryset with the number of subcategories."""

View File

@ -14,10 +14,7 @@
{% block heading %}
{% if category %}
{% trans "Part Category" %}:
{% settings_value "PART_CATEGORY_DEFAULT_ICON" as default_icon %}
{% if category.icon or default_icon %}
<span class="{{ category.icon|default:default_icon }}"></span>
{% endif %}
<span id='category-icon'></span>
{{ category.name }}
{% else %}
{% trans "Parts" %}
@ -234,6 +231,10 @@
{% block js_ready %}
{{ block.super }}
loadApiIconPacks().then(() => {
$('#category-icon').addClass(getApiIconClass('{{ category.icon }}'));
});
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
$('#category-stocktake').click(function() {
@ -242,13 +243,11 @@
{% if category %}value: {{ category.pk }},{% endif %}
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
location: {
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
generate_report: {},
@ -308,7 +307,6 @@
return node;
},
defaultIcon: global_settings.PART_CATEGORY_DEFAULT_ICON,
});
onPanelLoad('subcategories', function() {

View File

@ -441,7 +441,6 @@
location: {
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
generate_report: {

View File

@ -4,6 +4,8 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.test import TestCase
from common.models import InvenTreeSetting
from .models import Part, PartCategory, PartParameter, PartParameterTemplate
@ -412,3 +414,29 @@ class CategoryTest(TestCase):
# should log an exception
with self.assertRaises(ValidationError):
B3.delete()
def test_icon(self):
"""Test the category icon."""
# No default icon set
cat = PartCategory.objects.create(name='Test Category')
self.assertEqual(cat.icon, '')
# Set a default icon
InvenTreeSetting.set_setting('PART_CATEGORY_DEFAULT_ICON', 'ti:package:outline')
self.assertEqual(cat.icon, 'ti:package:outline')
# Set custom icon to default icon and assert that it does not get written to the database
cat.icon = 'ti:package:outline'
cat.save()
self.assertEqual(cat._icon, '')
# Set a different custom icon and assert that it takes precedence
cat.icon = 'ti:tag:outline'
cat.save()
self.assertEqual(cat.icon, 'ti:tag:outline')
InvenTreeSetting.set_setting('PART_CATEGORY_DEFAULT_ICON', '')
# Test that the icon can be set to None again
cat.icon = ''
cat.save()
self.assertEqual(cat.icon, '')

View File

@ -0,0 +1,34 @@
"""Plugin mixin classes for icon pack plugin."""
import logging
from common.icons import IconPack, reload_icon_packs
from plugin.helpers import MixinNotImplementedError
logger = logging.getLogger('inventree')
class IconPackMixin:
"""Mixin that add custom icon packs."""
class MixinMeta:
"""Meta options for this mixin."""
MIXIN_NAME = 'icon_pack'
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('icon_pack', True, __class__)
@classmethod
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
"""Activate icon pack plugins."""
logger.debug('Reloading icon packs')
reload_icon_packs()
def icon_packs(self) -> list[IconPack]:
"""Return a list of custom icon packs."""
raise MixinNotImplementedError(
f"{__class__} is missing the 'icon_packs' method"
)

View File

@ -4,6 +4,7 @@ from common.notifications import BulkNotificationMethod, SingleNotificationMetho
from plugin.base.action.mixins import ActionMixin
from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin
from plugin.base.event.mixins import EventMixin
from plugin.base.icons.mixins import IconPackMixin
from plugin.base.integration.APICallMixin import APICallMixin
from plugin.base.integration.AppMixin import AppMixin
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
@ -22,6 +23,7 @@ __all__ = [
'AppMixin',
'CurrencyExchangeMixin',
'EventMixin',
'IconPackMixin',
'LabelPrintingMixin',
'NavigationMixin',
'ReportMixin',

View File

@ -0,0 +1,39 @@
"""Sample icon pack plugin to add custom icons."""
from django.templatetags.static import static
from common.icons import IconPack
from plugin.base.icons.mixins import IconPackMixin
from plugin.plugin import InvenTreePlugin
class SampleIconPlugin(IconPackMixin, InvenTreePlugin):
"""Example plugin to add custom icons."""
NAME = 'SampleIconPackPlugin'
SLUG = 'sampleicons'
TITLE = 'My sample icon pack plugin'
VERSION = '0.0.1'
def icon_packs(self):
"""Return a list of custom icon packs."""
return [
IconPack(
name='My Custom Icons',
prefix='my',
fonts={
'woff2': static('fontawesome/webfonts/fa-regular-400.woff2'),
'woff': static('fontawesome/webfonts/fa-regular-400.woff'),
'truetype': static('fontawesome/webfonts/fa-regular-400.ttf'),
},
icons={
'my-icon': {
'name': 'My Icon',
'category': '',
'tags': ['my', 'icon'],
'variants': {'filled': 'f0a5', 'cool': 'f073'},
}
},
)
]

View File

@ -0,0 +1,52 @@
"""Unit tests for icon pack sample plugins."""
from django.urls import reverse
from InvenTree.unit_test import InvenTreeAPITestCase
from plugin import InvenTreePlugin, registry
from plugin.helpers import MixinNotImplementedError
from plugin.mixins import IconPackMixin
class SampleIconPackPluginTests(InvenTreeAPITestCase):
"""Tests for SampleIconPackPlugin."""
def test_get_icons_api(self):
"""Check get icons api."""
# Activate plugin
config = registry.get_plugin('sampleicons').plugin_config()
config.active = True
config.save()
response = self.get(reverse('api-icon-list'), expected_code=200)
self.assertEqual(len(response.data), 2)
for icon_pack in response.data:
if icon_pack['prefix'] == 'my':
break
else:
self.fail('My icon pack not found')
self.assertEqual(icon_pack['prefix'], 'my')
self.assertEqual(icon_pack['name'], 'My Custom Icons')
for font_format in ['woff2', 'woff', 'truetype']:
self.assertIn(font_format, icon_pack['fonts'])
self.assertEqual(len(icon_pack['icons']), 1)
self.assertEqual(icon_pack['icons']['my-icon']['name'], 'My Icon')
self.assertEqual(icon_pack['icons']['my-icon']['category'], '')
self.assertEqual(icon_pack['icons']['my-icon']['tags'], ['my', 'icon'])
self.assertEqual(
icon_pack['icons']['my-icon']['variants'],
{'filled': 'f0a5', 'cool': 'f073'},
)
def test_mixin(self):
"""Test that MixinNotImplementedError is raised."""
class Wrong(IconPackMixin, InvenTreePlugin):
pass
with self.assertRaises(MixinNotImplementedError):
plugin = Wrong()
plugin.icon_packs()

View File

@ -7,11 +7,13 @@ from decimal import Decimal
from django import template
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.safestring import SafeString, mark_safe
from django.utils.translation import gettext_lazy as _
from PIL import Image
import common.icons
import InvenTree.helpers
import InvenTree.helpers_model
import report.helpers
@ -473,3 +475,61 @@ def format_date(date, timezone=None, format=None):
return date.strftime(format)
else:
return date.isoformat()
@register.simple_tag()
def icon(name, **kwargs):
"""Render an icon from the icon packs.
Arguments:
name: The name of the icon to render
Keyword Arguments:
class: Optional class name(s) to apply to the icon element
"""
if not name:
return ''
try:
pack, icon, variant = common.icons.validate_icon(name)
except ValidationError:
return ''
unicode = chr(int(icon['variants'][variant], 16))
return mark_safe(
f'<i class="icon {kwargs.get("class", "")}" style="font-family: inventree-icon-font-{pack.prefix}">{unicode}</i>'
)
@register.simple_tag()
def include_icon_fonts():
"""Return the CSS font-face rule for the icon fonts used on the current page (or all)."""
fonts = []
for font in common.icons.get_icon_packs().values():
# generate the font src string (prefer ttf over woff, woff2 is not supported by weasyprint)
if 'truetype' in font.fonts:
font_format, url = 'truetype', font.fonts['truetype']
elif 'woff' in font.fonts:
font_format, url = 'woff', font.fonts['woff']
fonts.append(f"""
@font-face {'{'}
font-family: 'inventree-icon-font-{font.prefix}';
src: url('{InvenTree.helpers_model.construct_absolute_url(url)}') format('{font_format}');
{'}'}\n""")
icon_class = f"""
.icon {'{'}
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better font rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
{'}'}
"""
return mark_safe(icon_class + '\n'.join(fonts))

View File

@ -186,6 +186,31 @@ class ReportTagTest(TestCase):
result = report_tags.format_datetime(time, tz, fmt)
self.assertEqual(result, expected)
def test_icon(self):
"""Test the icon template tag."""
for icon in [None, '', 'not:the-correct-format', 'any-non-existent-icon']:
self.assertEqual(report_tags.icon(icon), '')
self.assertEqual(
report_tags.icon('ti:package:outline'),
f'<i class="icon " style="font-family: inventree-icon-font-ti">{chr(int("eaff", 16))}</i>',
)
self.assertEqual(
report_tags.icon(
'ti:package:outline', **{'class': 'my-custom-class my-seconds-class'}
),
f'<i class="icon my-custom-class my-seconds-class" style="font-family: inventree-icon-font-ti">{chr(int("eaff", 16))}</i>',
)
def test_include_icon_fonts(self):
"""Test the include_icon_fonts template tag."""
style = report_tags.include_icon_fonts()
self.assertIn('@font-face {', style)
self.assertIn("font-family: 'inventree-icon-font-ti';", style)
self.assertIn('tabler-icons/tabler-icons.ttf', style)
self.assertIn('.icon {', style)
class BarcodeTagTest(TestCase):
"""Unit tests for the barcode template tags."""

View File

@ -0,0 +1,67 @@
"""This script updates the vendored tabler icons package."""
import json
import os
import shutil
import tempfile
if __name__ == '__main__':
MY_DIR = os.path.dirname(os.path.realpath(__file__))
STATIC_FOLDER = os.path.abspath(
os.path.join(MY_DIR, '..', 'InvenTree', 'static', 'tabler-icons')
)
TMP_FOLDER = os.path.join(tempfile.gettempdir(), 'tabler-icons')
if not os.path.exists(TMP_FOLDER):
os.mkdir(TMP_FOLDER)
if not os.path.exists(STATIC_FOLDER):
os.mkdir(STATIC_FOLDER)
print('Downloading tabler icons...')
os.system(f'npm install --prefix {TMP_FOLDER} @tabler/icons @tabler/icons-webfont')
print(f'Copying tabler icons to {STATIC_FOLDER}...')
for font in ['tabler-icons.woff', 'tabler-icons.woff2', 'tabler-icons.ttf']:
shutil.copyfile(
os.path.join(
TMP_FOLDER,
'node_modules',
'@tabler',
'icons-webfont',
'dist',
'fonts',
font,
),
os.path.join(STATIC_FOLDER, font),
)
# Copy license
shutil.copyfile(
os.path.join(TMP_FOLDER, 'node_modules', '@tabler', 'icons-webfont', 'LICENSE'),
os.path.join(STATIC_FOLDER, 'LICENSE'),
)
print('Generating icon list...')
with open(
os.path.join(TMP_FOLDER, 'node_modules', '@tabler', 'icons', 'icons.json'), 'r'
) as f:
icons = json.load(f)
res = {}
for icon in icons.values():
res[icon['name']] = {
'name': icon['name'],
'category': icon['category'],
'tags': icon['tags'],
'variants': {
name: style['unicode'] for name, style in icon['styles'].items()
},
}
with open(os.path.join(STATIC_FOLDER, 'icons.json'), 'w') as f:
json.dump(res, f, separators=(',', ':'))
print('Cleaning up...')
shutil.rmtree(TMP_FOLDER)

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.11 on 2024-07-20 22:30
import common.icons
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0111_delete_stockitemattachment'),
]
operations = [
migrations.AlterField(
model_name='stocklocation',
name='custom_icon',
field=models.CharField(blank=True, db_column='icon', help_text='Icon (optional)', max_length=100, validators=[common.icons.validate_icon], verbose_name='Icon'),
),
migrations.AlterField(
model_name='stocklocationtype',
name='icon',
field=models.CharField(blank=True, help_text='Default icon for all locations that have no icon set (optional)', max_length=100, validators=[common.icons.validate_icon], verbose_name='Icon'),
),
]

View File

@ -33,6 +33,7 @@ import InvenTree.ready
import InvenTree.tasks
import report.mixins
import report.models
from common.icons import validate_icon
from common.settings import get_global_setting
from company import models as CompanyModels
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
@ -86,6 +87,7 @@ class StockLocationType(InvenTree.models.MetadataMixin, models.Model):
max_length=100,
verbose_name=_('Icon'),
help_text=_('Default icon for all locations that have no icon set (optional)'),
validators=[validate_icon],
)
@ -117,6 +119,8 @@ class StockLocation(
ITEM_PARENT_KEY = 'location'
EXTRA_PATH_FIELDS = ['icon']
objects = StockLocationManager()
class Meta:
@ -163,6 +167,7 @@ class StockLocation(
verbose_name=_('Icon'),
help_text=_('Icon (optional)'),
db_column='icon',
validators=[validate_icon],
)
owner = models.ForeignKey(
@ -212,6 +217,11 @@ class StockLocation(
if self.location_type:
return self.location_type.icon
if default_icon := get_global_setting(
'STOCK_LOCATION_DEFAULT_ICON', cache=True
):
return default_icon
return ''
@icon.setter

View File

@ -646,7 +646,6 @@ $("#stock-return-from-customer").click(function() {
{% endif %}
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
notes: {

View File

@ -15,10 +15,7 @@
{% block heading %}
{% if location %}
{% trans "Stock Location" %}:
{% settings_value "STOCK_LOCATION_DEFAULT_ICON" as default_icon %}
{% if location.icon or default_icon %}
<span class="{{ location.icon|default:default_icon }}"></span>
{% endif %}
<span id="location-icon"></span>
{{ location.name }}
{% else %}
{% trans "Stock" %}
@ -244,6 +241,10 @@
{% block js_ready %}
{{ block.super }}
loadApiIconPacks().then(() => {
$('#location-icon').addClass(getApiIconClass('{{ location.icon }}'));
});
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
$('#location-stocktake').click(function() {
@ -251,14 +252,12 @@
category: {
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
location: {
{% if location %}value: {{ location.pk }},{% endif %}
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
generate_report: {},
@ -455,7 +454,6 @@
return node;
},
defaultIcon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
});
{% endblock js_ready %}

View File

@ -472,13 +472,13 @@ class StockLocationTypeTest(StockAPITestCase):
"""Test that the list endpoint works as expected."""
location_types = [
StockLocationType.objects.create(
name='Type 1', description='Type 1 desc', icon='fas fa-box'
name='Type 1', description='Type 1 desc', icon='ti:package:outline'
),
StockLocationType.objects.create(
name='Type 2', description='Type 2 desc', icon='fas fa-box'
name='Type 2', description='Type 2 desc', icon='ti:package:outline'
),
StockLocationType.objects.create(
name='Type 3', description='Type 3 desc', icon='fas fa-box'
name='Type 3', description='Type 3 desc', icon='ti:package:outline'
),
]
@ -493,7 +493,7 @@ class StockLocationTypeTest(StockAPITestCase):
def test_delete(self):
"""Test that we can delete a location type via API."""
location_type = StockLocationType.objects.create(
name='Type 1', description='Type 1 desc', icon='fas fa-box'
name='Type 1', description='Type 1 desc', icon='ti:package:outline'
)
self.delete(
reverse('api-location-type-detail', kwargs={'pk': location_type.pk}),
@ -506,8 +506,19 @@ class StockLocationTypeTest(StockAPITestCase):
self.post(
self.list_url,
{'name': 'Test Type 1', 'description': 'Test desc 1', 'icon': 'fas fa-box'},
expected_code=400,
)
self.post(
self.list_url,
{
'name': 'Test Type 1',
'description': 'Test desc 1',
'icon': 'ti:package:outline',
},
expected_code=201,
)
self.assertIsNotNone(
StockLocationType.objects.filter(name='Test Type 1').first()
)
@ -515,14 +526,20 @@ class StockLocationTypeTest(StockAPITestCase):
def test_update(self):
"""Test that we can update a location type via API."""
location_type = StockLocationType.objects.create(
name='Type 1', description='Type 1 desc', icon='fas fa-box'
name='Type 1', description='Type 1 desc', icon='ti:package:outline'
)
res = self.patch(
self.patch(
reverse('api-location-type-detail', kwargs={'pk': location_type.pk}),
{'icon': 'fas fa-shapes'},
expected_code=400,
)
res = self.patch(
reverse('api-location-type-detail', kwargs={'pk': location_type.pk}),
{'icon': 'ti:tag:outline'},
expected_code=200,
).json()
self.assertEqual(res['icon'], 'fas fa-shapes')
self.assertEqual(res['icon'], 'ti:tag:outline')
class StockItemListTest(StockAPITestCase):

View File

@ -15,7 +15,13 @@ from order.models import SalesOrder
from part.models import Part, PartTestTemplate
from stock.status_codes import StockHistoryCode
from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation
from .models import (
StockItem,
StockItemTestResult,
StockItemTracking,
StockLocation,
StockLocationType,
)
class StockTestBase(InvenTreeTestCase):
@ -1305,3 +1311,39 @@ class TestResultTest(StockTestBase):
tests = item.testResultMap(include_installed=False)
self.assertEqual(len(tests), 3)
self.assertNotIn('somenewtest', tests)
class StockLocationTest(InvenTreeTestCase):
"""Tests for the StockLocation model."""
def test_icon(self):
"""Test stock location icon."""
# No default icon set
loc = StockLocation.objects.create(name='Test Location')
loc_type = StockLocationType.objects.create(
name='Test Type', icon='ti:cube-send:outline'
)
self.assertEqual(loc.icon, '')
# Set a default icon
InvenTreeSetting.set_setting(
'STOCK_LOCATION_DEFAULT_ICON', 'ti:package:outline'
)
self.assertEqual(loc.icon, 'ti:package:outline')
# Assign location type and check that it takes precedence over default icon
loc.location_type = loc_type
loc.save()
self.assertEqual(loc.icon, 'ti:cube-send:outline')
# Set a custom icon and assert that it takes precedence over all other icons
loc.icon = 'ti:tag:outline'
loc.save()
self.assertEqual(loc.icon, 'ti:tag:outline')
InvenTreeSetting.set_setting('STOCK_LOCATION_DEFAULT_ICON', '')
# Test that the icon can be set to None again
loc.icon = ''
loc.location_type = None
loc.save()
self.assertEqual(loc.icon, '')

View File

@ -330,7 +330,6 @@ onPanelLoad('category', function() {
icon: 'fa-sitemap',
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
default_value: {},
@ -394,7 +393,6 @@ onPanelLoad('category', function() {
value: pk,
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
default_value: {},
@ -429,7 +427,8 @@ onPanelLoad('parts', function() {
});
// Javascript for the Stock settings panel
onPanelLoad("stock", function() {
onPanelLoad("stock", async function() {
await loadApiIconPacks();
// Construct the stock location type table
$('#location-type-table').bootstrapTable({
@ -440,6 +439,15 @@ onPanelLoad("stock", function() {
return '{% trans "No stock location types found" escape %}';
},
columns: [
{
field: 'icon',
sortable: true,
title: '{% trans "Icon" escape %}',
width: "50px",
formatter: function(value, row) {
return `<span class="${getApiIconClass(value)}"></span>`
}
},
{
field: 'name',
sortable: true,
@ -450,11 +458,6 @@ onPanelLoad("stock", function() {
sortable: false,
title: '{% trans "Description" escape %}',
},
{
field: 'icon',
sortable: true,
title: '{% trans "Icon" escape %}',
},
{
field: 'location_count',
sortable: true,
@ -560,13 +563,11 @@ onPanelLoad('stocktake', function() {
category: {
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
location: {
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
generate_report: {},

View File

@ -1,5 +1,7 @@
/* globals
getApiIconClass,
inventreeGet,
loadApiIconPacks,
*/
/* exported
@ -180,6 +182,11 @@ function generateTreeStructure(data, options) {
if (options.processNode) {
node = options.processNode(node);
if (node.icon) {
node.icon = getApiIconClass(node.icon);
}
data[ii] = node;
}
}
@ -213,7 +220,7 @@ function generateTreeStructure(data, options) {
/**
* Enable support for breadcrumb tree navigation on this page
*/
function enableBreadcrumbTree(options) {
async function enableBreadcrumbTree(options) {
const label = options.label;
@ -224,6 +231,8 @@ function enableBreadcrumbTree(options) {
const filters = options.filters || {};
await loadApiIconPacks();
inventreeGet(
options.url,
filters,

View File

@ -619,7 +619,6 @@ function completeBuildOutputs(build_id, outputs, options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
notes: {
@ -753,7 +752,6 @@ function scrapBuildOutputs(build_id, outputs, options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
notes: {},
@ -2131,7 +2129,6 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
exclude_location: {},

View File

@ -2251,7 +2251,6 @@ function initializeRelatedField(field, fields, options={}) {
data: rootNodes,
expandIcon: 'fas fa-plus-square large-treeview-icon',
collapseIcon: 'fa fa-minus-square large-treeview-icon',
nodeIcon: field.tree_picker.defaultIcon,
color: "black",
});
}

View File

@ -14,7 +14,10 @@
deleteButton,
editButton,
formatDecimal,
getApiIcon,
getApiIconClass,
imageHoverIcon,
loadApiIconPacks,
makeCopyButton,
makeDeleteButton,
makeEditButton,
@ -594,3 +597,66 @@ function renderClipboard(s, prepend=false) {
return `<div class="flex-cell">${s+clipString}</div>`;
}
}
async function loadApiIconPacks() {
if(!window._ICON_PACKS) {
const packs = await inventreeGet('{% url "api-icon-list" %}');
window._ICON_PACKS = Object.fromEntries(packs.map(pack => [pack.prefix, pack]));
await Promise.all(
packs.map(async (pack) => {
const fontName = `inventree-icon-font-${pack.prefix}`;
const src = Object.entries(pack.fonts).map(([format, url]) => `url(${url}) format("${format}")`).join(',\n');
const font = new FontFace(fontName, src + ";");
await font.load();
document.fonts.add(font);
return font;
})
)
}
return window._ICON_PACKS;
}
function getApiIcon(name) {
if(!window._ICON_PACKS) return;
const [_iconPackage, _name, _variant] = name.split(':');
const iconPackage = window._ICON_PACKS[_iconPackage];
if(!iconPackage) return;
const icon = iconPackage.icons[_name];
if(!icon) return;
const variant = icon.variants[_variant];
if(!variant) return;
return [`inventree-icon-font-${_iconPackage}`, variant];
}
function getApiIconClass(name) {
const icon = getApiIcon(name);
if(!icon) return "";
const [font, hexContent] = icon;
let styleTag = document.getElementById('api-icon-styles');
if(!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'api-icon-styles';
styleTag.type = 'text/css';
document.head.appendChild(styleTag);
}
const className = `icon-${name.replace(/:/g, '-')}`;
if (!styleTag.textContent.includes(`.${className}`)) {
styleTag.textContent += `.${className} { font-family: ${font}; } .${className}:before { content: "\\${hexContent}"; }\n`;
}
return `api-icon ${className}`;
}

View File

@ -2,6 +2,7 @@
/* globals
blankImage,
getApiIconClass,
partStockLabel,
renderLink,
select2Thumbnail
@ -274,7 +275,7 @@ function renderStockLocation(data, parameters={}) {
function renderStockLocationType(data, parameters={}) {
return renderModel(
{
text: `<span class="${data.icon} me-1"></span>${data.name}`,
text: `<span class="${getApiIconClass(data.icon)} me-1"></span>${data.name}`,
},
parameters
);

View File

@ -11,6 +11,7 @@
formatCurrency,
formatDecimal,
formatPriceRange,
getApiIconClass,
getCurrencyConversionRates,
getFormFieldValue,
getTableData,
@ -130,7 +131,6 @@ function partFields(options={}) {
},
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
name: {},
@ -155,7 +155,6 @@ function partFields(options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
default_supplier: {
@ -311,7 +310,6 @@ function categoryFields(options={}) {
required: false,
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
name: {},
@ -323,7 +321,6 @@ function categoryFields(options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
default_keywords: {
@ -331,8 +328,9 @@ function categoryFields(options={}) {
},
structural: {},
icon: {
help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
placeholder: 'fas fa-tag',
help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer">Tabler Icons</a>.`,
placeholder: 'ti:<icon-name>:<variant> (e.g. ti:alert-circle:filled)',
icon: "fa-icons",
},
};
@ -2215,7 +2213,6 @@ function setPartCategory(data, options={}) {
category: {
tree_picker: {
url: '{% url "api-part-category-tree" %}',
default_icon: global_settings.PART_CATEGORY_DEFAULT_ICON,
},
},
},
@ -2782,9 +2779,8 @@ function loadPartCategoryTable(table, options) {
}
}
const icon = row.icon || global_settings.PART_CATEGORY_DEFAULT_ICON;
if (icon) {
html += `<span class="${icon} me-1"></span>`;
if (row.icon) {
html += `<span class="${getApiIconClass(row.icon)} me-1"></span>`;
}
html += renderLink(

View File

@ -1387,7 +1387,6 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
},

View File

@ -552,7 +552,6 @@ function receiveReturnOrderItems(order_id, line_items, options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
}
},

View File

@ -16,6 +16,7 @@
formatCurrency,
formatDecimal,
formatPriceRange,
getApiIconClass,
getCurrencyConversionRates,
getFormFieldElement,
getFormFieldValue,
@ -137,8 +138,8 @@ function stockLocationTypeFields() {
name: {},
description: {},
icon: {
help_text: `{% trans "Default icon for all locations that have no icon set (optional) - Explore all available icons on" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
placeholder: 'fas fa-box',
help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer">Tabler Icons</a>.`,
placeholder: 'ti:<icon-name>:<variant> (e.g. ti:alert-circle:filled)',
icon: "fa-icons",
},
}
@ -154,7 +155,6 @@ function stockLocationFields(options={}) {
required: false,
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
name: {},
@ -173,8 +173,8 @@ function stockLocationFields(options={}) {
},
},
custom_icon: {
help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`,
placeholder: 'fas fa-box',
help_text: `{% trans "Icon (optional) - Explore all available icons on" %} <a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer">Tabler Icons</a>.`,
placeholder: 'ti:<icon-name>:<variant> (e.g. ti:alert-circle:filled)',
icon: "fa-icons",
},
};
@ -356,7 +356,6 @@ function stockItemFields(options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
quantity: {
@ -916,7 +915,6 @@ function mergeStockItems(items, options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
notes: {
@ -2811,9 +2809,8 @@ function loadStockLocationTable(table, options) {
}
}
const icon = row.icon || global_settings.STOCK_LOCATION_DEFAULT_ICON;
if (icon) {
html += `<span class="${icon} me-1"></span>`;
if (row.icon) {
html += `<span class="${getApiIconClass(row.icon)} me-1"></span>`;
}
html += renderLink(
@ -3262,7 +3259,6 @@ function uninstallStockItem(installed_item_id, options={}) {
},
tree_picker: {
url: '{% url "api-location-tree" %}',
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
},
},
note: {

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -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)
}
>
<Group gap={4}>
{breadcrumb.icon}
<Text size="sm">{breadcrumb.name}</Text>
</Group>
</Anchor>
);
})}

View File

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

View File

@ -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 && (
<Group gap="xs">
{icon}
<Text size="md" truncate>
{subtitle}
</Text>
</Group>
)}
</Stack>
</Group>

View File

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

View File

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

View File

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

View File

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

View File

@ -132,7 +132,9 @@ export function partCategoryFields(): ApiFormFieldSet {
},
default_keywords: {},
structural: {},
icon: {}
icon: {
field_type: 'icon'
}
};
return fields;

View File

@ -909,7 +909,9 @@ export function stockLocationFields(): ApiFormFieldSet {
description: {},
structural: {},
external: {},
custom_icon: {},
custom_icon: {
field_type: 'icon'
},
location_type: {}
};

View File

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

View File

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

View 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])
)
});
}
}));

View File

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

View File

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

View File

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

View File

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

View File

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