mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-07 02:03:50 +00:00
Migrate Icons to Tabler icons and integrate into PUI (#7684)
* add icon backend implementation * implement pui icon picker * integrate icons in PUI * Bump API version * PUI: add icon to detail pages top header * CUI: explain icon format and change link to tabler icons site * CUI: use new icon packs * move default icon implementation to backend * add icon template tag to use in report printing * add missing migrations * fit to previous schema with part category icon * fit to previous schema with part category icon * add icon pack plugin integration * Add custom command to migrate icons * add docs * fix: tests * fix: tests * add tests * fix: tests * fix: tests * fix: tests * fix tests * fix sonarcloud issues * add logging * remove unneded pass * significantly improve performance of icon picker component
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
+29
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -441,7 +441,6 @@
|
||||
location: {
|
||||
tree_picker: {
|
||||
url: '{% url "api-location-tree" %}',
|
||||
default_icon: global_settings.STOCK_LOCATION_DEFAULT_ICON,
|
||||
},
|
||||
},
|
||||
generate_report: {
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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'},
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
+24
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user