mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
d5afc37264
commit
96abd0898c
26
.vscode/launch.json
vendored
26
.vscode/launch.json
vendored
@ -6,19 +6,37 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "InvenTree Server",
|
||||
"type": "python",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"args": [
|
||||
"runserver",
|
||||
// "0.0.0.0:8000", // expose server in network (useful for testing with mobile app)
|
||||
// "--noreload" // disable auto-reload
|
||||
],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "InvenTree Server - Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": [
|
||||
"test",
|
||||
// "part.test_api.PartCategoryAPITest", // run only a specific test
|
||||
],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "InvenTree Server - 3rd party",
|
||||
"type": "python",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
|
||||
"args": ["runserver"],
|
||||
"args": [
|
||||
"runserver"
|
||||
],
|
||||
"django": true,
|
||||
"justMyCode": false
|
||||
},
|
||||
|
19
docs/docs/extend/plugins/icon.md
Normal file
19
docs/docs/extend/plugins/icon.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Icon Pack Mixin
|
||||
---
|
||||
|
||||
## IconPackMixin
|
||||
|
||||
The IconPackMixin class provides basic functionality for letting plugins expose custom icon packs that are available in the InvenTree UI. This is especially useful to provide a custom crafted icon pack with icons for different location types, e.g. different sizes and styles of drawers, bags, ESD bags, ... which are not available in the standard tabler icons library.
|
||||
|
||||
### Sample Plugin
|
||||
|
||||
The following example demonstrates how to use the `IconPackMixin` class to add a custom icon pack:
|
||||
|
||||
::: plugin.samples.icons.icon_sample.SampleIconPlugin
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
show_source: True
|
||||
members: []
|
@ -259,6 +259,31 @@ A shortcut function is provided for rendering an image associated with a Company
|
||||
|
||||
*Preview* and *thumbnail* image variations can be rendered for the `company_image` tag, in a similar manner to [part image variations](#image-variations)
|
||||
|
||||
## Icons
|
||||
|
||||
Some models (e.g. part categories and locations) allow to specify a custom icon. To render these icons in a report, there is a `{% raw %}{% icon location.icon %}{% endraw %}` template tag from the report template library available.
|
||||
|
||||
This tag renders the required html for the icon.
|
||||
|
||||
!!! info "Loading fonts"
|
||||
Additionally the icon fonts need to be loaded into the template. This can be done using the `{% raw %}{% include_icon_fonts %}{% endraw %}` template tag inside of a style block
|
||||
|
||||
!!! tip "Custom classes for styling the icon further"
|
||||
The icon template tag accepts an optional `class` argument which can be used to apply a custom class to the rendered icon used to style the icon further e.g. positioning it, changing it's size, ... `{% raw %}{% icon location.icon class="my-class" %}{% endraw %}`.
|
||||
|
||||
```html
|
||||
{% raw %}
|
||||
{% load report %}
|
||||
|
||||
{% block style %}
|
||||
{% include_icon_fonts %}
|
||||
{% endblock style %}
|
||||
|
||||
{% icon location.icon %}
|
||||
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
## InvenTree Logo
|
||||
|
||||
A template tag is provided to load the InvenTree logo image into a report. You can render the logo using the `{% raw %}{% logo_image %}{% endraw %}` tag:
|
||||
|
@ -6,6 +6,14 @@ title: Stock
|
||||
|
||||
A stock location represents a physical real-world location where *Stock Items* are stored. Locations are arranged in a cascading manner and each location may contain multiple sub-locations, or stock, or both.
|
||||
|
||||
### Icons
|
||||
|
||||
Stock locations can be assigned custom icons (either directly or through [Stock Location Types](#stock-location-type)). When using PUI there is a custom icon picker component available that can help to select the right icon. However in CUI the icon needs to be entered manually.
|
||||
|
||||
By default, the tabler icons package (with prefix: `ti`) is available. To manually select an item, search on the [tabler icons](https://tabler.io/icons) page for an icon and copy its name e.g. `bookmark`. Some icons have a filled and an outline version (if no variants are specified, it's an outline variant). Now these values can be put into the format: `<package-prefix>:<icon-name>:<variant>`. E.g. `ti:bookmark:outline` or `ti:bookmark:filled`.
|
||||
|
||||
If there are some icons missing in the tabler icons package, users can even install their own custom icon packs through a plugin. See [`IconPackMixin`](../extend/plugins/icon.md).
|
||||
|
||||
## Stock Location Type
|
||||
|
||||
A stock location type represents a specific type of location (e.g. one specific size of drawer, shelf, ... or box) which can be assigned to multiple stock locations. In the first place, it is used to specify an icon and having the icon in sync for all locations that use this location type, but it also serves as a data field to quickly see what type of location this is. It is planned to add e.g. drawer dimension information to the location type to add a "find a matching, empty stock location" tool.
|
||||
|
@ -203,6 +203,7 @@ nav:
|
||||
- Barcode Mixin: extend/plugins/barcode.md
|
||||
- Currency Mixin: extend/plugins/currency.md
|
||||
- Event Mixin: extend/plugins/event.md
|
||||
- Icon Pack Mixin: extend/plugins/icon.md
|
||||
- Label Printing Mixin: extend/plugins/label.md
|
||||
- Locate Mixin: extend/plugins/locate.md
|
||||
- Navigation Mixin: extend/plugins/navigation.md
|
||||
|
@ -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;
|
||||
}
|
||||
|
21
src/backend/InvenTree/InvenTree/static/tabler-icons/LICENSE
Normal file
21
src/backend/InvenTree/InvenTree/static/tabler-icons/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2024 Paweł Kuna
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
File diff suppressed because one or more lines are too long
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 = [
|
||||
|
114
src/backend/InvenTree/common/icons.py
Normal file
114
src/backend/InvenTree/common/icons.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Icon utilities for InvenTree."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.templatetags.static import static
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
_icon_packs = None
|
||||
|
||||
|
||||
class Icon(TypedDict):
|
||||
"""Dict type for an icon.
|
||||
|
||||
Attributes:
|
||||
name: The name of the icon.
|
||||
category: The category of the icon.
|
||||
tags: A list of tags for the icon (used for search).
|
||||
variants: A dictionary of variants for the icon, where the key is the variant name and the value is the variant's unicode hex character.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
tags: list[str]
|
||||
variants: dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class IconPack:
|
||||
"""Dataclass for an icon pack.
|
||||
|
||||
Attributes:
|
||||
name: The name of the icon pack.
|
||||
prefix: The prefix used for the icon pack.
|
||||
fonts: A dictionary of different font file formats for the icon pack, where the key is the css format and the value a path to the font file.
|
||||
icons: A dictionary of icons in the icon pack, where the key is the icon name and the value is a dictionary of the icon's variants.
|
||||
"""
|
||||
|
||||
name: str
|
||||
prefix: str
|
||||
fonts: dict[str, str]
|
||||
icons: dict[str, Icon]
|
||||
|
||||
|
||||
def get_icon_packs():
|
||||
"""Return a dictionary of available icon packs including their icons."""
|
||||
global _icon_packs
|
||||
|
||||
if _icon_packs is None:
|
||||
tabler_icons_path = Path(__file__).parent.parent.joinpath(
|
||||
'InvenTree/static/tabler-icons/icons.json'
|
||||
)
|
||||
with open(tabler_icons_path, 'r') as tabler_icons_file:
|
||||
tabler_icons = json.load(tabler_icons_file)
|
||||
|
||||
icon_packs = [
|
||||
IconPack(
|
||||
name='Tabler Icons',
|
||||
prefix='ti',
|
||||
fonts={
|
||||
'woff2': static('tabler-icons/tabler-icons.woff2'),
|
||||
'woff': static('tabler-icons/tabler-icons.woff'),
|
||||
'truetype': static('tabler-icons/tabler-icons.ttf'),
|
||||
},
|
||||
icons=tabler_icons,
|
||||
)
|
||||
]
|
||||
|
||||
from plugin import registry
|
||||
|
||||
for plugin in registry.with_mixin('icon_pack', active=True):
|
||||
try:
|
||||
icon_packs.extend(plugin.icon_packs())
|
||||
except Exception as e:
|
||||
logger.warning('Error loading icon pack from plugin %s: %s', plugin, e)
|
||||
|
||||
_icon_packs = {pack.prefix: pack for pack in icon_packs}
|
||||
|
||||
return _icon_packs
|
||||
|
||||
|
||||
def reload_icon_packs():
|
||||
"""Reload the icon packs."""
|
||||
global _icon_packs
|
||||
_icon_packs = None
|
||||
get_icon_packs()
|
||||
|
||||
|
||||
def validate_icon(icon: str):
|
||||
"""Validate an icon string in the format pack:name:variant."""
|
||||
try:
|
||||
pack, name, variant = icon.split(':')
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
f'Invalid icon format: {icon}, expected: pack:name:variant'
|
||||
)
|
||||
|
||||
packs = get_icon_packs()
|
||||
|
||||
if pack not in packs:
|
||||
raise ValidationError(f'Invalid icon pack: {pack}')
|
||||
|
||||
if name not in packs[pack].icons:
|
||||
raise ValidationError(f'Invalid icon name: {name}')
|
||||
|
||||
if variant not in packs[pack].icons[name]['variants']:
|
||||
raise ValidationError(f'Invalid icon variant: {variant}')
|
||||
|
||||
return packs[pack], packs[pack].icons[name], variant
|
@ -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)
|
||||
|
@ -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, '')
|
||||
|
34
src/backend/InvenTree/plugin/base/icons/mixins.py
Normal file
34
src/backend/InvenTree/plugin/base/icons/mixins.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Plugin mixin classes for icon pack plugin."""
|
||||
|
||||
import logging
|
||||
|
||||
from common.icons import IconPack, reload_icon_packs
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class IconPackMixin:
|
||||
"""Mixin that add custom icon packs."""
|
||||
|
||||
class MixinMeta:
|
||||
"""Meta options for this mixin."""
|
||||
|
||||
MIXIN_NAME = 'icon_pack'
|
||||
|
||||
def __init__(self):
|
||||
"""Register mixin."""
|
||||
super().__init__()
|
||||
self.add_mixin('icon_pack', True, __class__)
|
||||
|
||||
@classmethod
|
||||
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
||||
"""Activate icon pack plugins."""
|
||||
logger.debug('Reloading icon packs')
|
||||
reload_icon_packs()
|
||||
|
||||
def icon_packs(self) -> list[IconPack]:
|
||||
"""Return a list of custom icon packs."""
|
||||
raise MixinNotImplementedError(
|
||||
f"{__class__} is missing the 'icon_packs' method"
|
||||
)
|
@ -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',
|
||||
|
39
src/backend/InvenTree/plugin/samples/icons/icon_sample.py
Normal file
39
src/backend/InvenTree/plugin/samples/icons/icon_sample.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Sample icon pack plugin to add custom icons."""
|
||||
|
||||
from django.templatetags.static import static
|
||||
|
||||
from common.icons import IconPack
|
||||
from plugin.base.icons.mixins import IconPackMixin
|
||||
from plugin.plugin import InvenTreePlugin
|
||||
|
||||
|
||||
class SampleIconPlugin(IconPackMixin, InvenTreePlugin):
|
||||
"""Example plugin to add custom icons."""
|
||||
|
||||
NAME = 'SampleIconPackPlugin'
|
||||
SLUG = 'sampleicons'
|
||||
TITLE = 'My sample icon pack plugin'
|
||||
|
||||
VERSION = '0.0.1'
|
||||
|
||||
def icon_packs(self):
|
||||
"""Return a list of custom icon packs."""
|
||||
return [
|
||||
IconPack(
|
||||
name='My Custom Icons',
|
||||
prefix='my',
|
||||
fonts={
|
||||
'woff2': static('fontawesome/webfonts/fa-regular-400.woff2'),
|
||||
'woff': static('fontawesome/webfonts/fa-regular-400.woff'),
|
||||
'truetype': static('fontawesome/webfonts/fa-regular-400.ttf'),
|
||||
},
|
||||
icons={
|
||||
'my-icon': {
|
||||
'name': 'My Icon',
|
||||
'category': '',
|
||||
'tags': ['my', 'icon'],
|
||||
'variants': {'filled': 'f0a5', 'cool': 'f073'},
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
@ -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."""
|
||||
|
67
src/backend/InvenTree/script/update_icons.py
Normal file
67
src/backend/InvenTree/script/update_icons.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""This script updates the vendored tabler icons package."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
if __name__ == '__main__':
|
||||
MY_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
STATIC_FOLDER = os.path.abspath(
|
||||
os.path.join(MY_DIR, '..', 'InvenTree', 'static', 'tabler-icons')
|
||||
)
|
||||
TMP_FOLDER = os.path.join(tempfile.gettempdir(), 'tabler-icons')
|
||||
|
||||
if not os.path.exists(TMP_FOLDER):
|
||||
os.mkdir(TMP_FOLDER)
|
||||
|
||||
if not os.path.exists(STATIC_FOLDER):
|
||||
os.mkdir(STATIC_FOLDER)
|
||||
|
||||
print('Downloading tabler icons...')
|
||||
os.system(f'npm install --prefix {TMP_FOLDER} @tabler/icons @tabler/icons-webfont')
|
||||
|
||||
print(f'Copying tabler icons to {STATIC_FOLDER}...')
|
||||
|
||||
for font in ['tabler-icons.woff', 'tabler-icons.woff2', 'tabler-icons.ttf']:
|
||||
shutil.copyfile(
|
||||
os.path.join(
|
||||
TMP_FOLDER,
|
||||
'node_modules',
|
||||
'@tabler',
|
||||
'icons-webfont',
|
||||
'dist',
|
||||
'fonts',
|
||||
font,
|
||||
),
|
||||
os.path.join(STATIC_FOLDER, font),
|
||||
)
|
||||
|
||||
# Copy license
|
||||
shutil.copyfile(
|
||||
os.path.join(TMP_FOLDER, 'node_modules', '@tabler', 'icons-webfont', 'LICENSE'),
|
||||
os.path.join(STATIC_FOLDER, 'LICENSE'),
|
||||
)
|
||||
|
||||
print('Generating icon list...')
|
||||
with open(
|
||||
os.path.join(TMP_FOLDER, 'node_modules', '@tabler', 'icons', 'icons.json'), 'r'
|
||||
) as f:
|
||||
icons = json.load(f)
|
||||
|
||||
res = {}
|
||||
for icon in icons.values():
|
||||
res[icon['name']] = {
|
||||
'name': icon['name'],
|
||||
'category': icon['category'],
|
||||
'tags': icon['tags'],
|
||||
'variants': {
|
||||
name: style['unicode'] for name, style in icon['styles'].items()
|
||||
},
|
||||
}
|
||||
|
||||
with open(os.path.join(STATIC_FOLDER, 'icons.json'), 'w') as f:
|
||||
json.dump(res, f, separators=(',', ':'))
|
||||
|
||||
print('Cleaning up...')
|
||||
shutil.rmtree(TMP_FOLDER)
|
@ -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
|
||||
|
352
src/frontend/src/components/forms/fields/IconField.tsx
Normal file
352
src/frontend/src/components/forms/fields/IconField.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Box,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
ComboboxStore,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
useCombobox
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useElementSize } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
|
||||
import { useIconState } from '../../../states/IconState';
|
||||
import { ApiIcon } from '../../items/ApiIcon';
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
export default function IconField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
}>) {
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const { value } = field;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const combobox = useCombobox({
|
||||
onOpenedChange: (opened) => setOpen(opened)
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox store={combobox}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label={definition.label}
|
||||
description={definition.description}
|
||||
required={definition.required}
|
||||
error={error?.message}
|
||||
ref={field.ref}
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={
|
||||
value !== null && !definition.required ? (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => field.onChange(null)}
|
||||
/>
|
||||
) : (
|
||||
<Combobox.Chevron />
|
||||
)
|
||||
}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
rightSectionPointerEvents={value === null ? 'none' : 'all'}
|
||||
>
|
||||
{field.value ? (
|
||||
<Group gap="xs">
|
||||
<ApiIcon name={field.value} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{field.value}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Input.Placeholder>
|
||||
<Trans>No icon selected</Trans>
|
||||
</Input.Placeholder>
|
||||
)}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<ComboboxDropdown
|
||||
definition={definition}
|
||||
value={value}
|
||||
combobox={combobox}
|
||||
onChange={field.onChange}
|
||||
open={open}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderIconType = {
|
||||
package: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
variant: string;
|
||||
};
|
||||
|
||||
function ComboboxDropdown({
|
||||
definition,
|
||||
value,
|
||||
combobox,
|
||||
onChange,
|
||||
open
|
||||
}: Readonly<{
|
||||
definition: ApiFormFieldType;
|
||||
value: null | string;
|
||||
combobox: ComboboxStore;
|
||||
onChange: (newVal: string | null) => void;
|
||||
open: boolean;
|
||||
}>) {
|
||||
const iconPacks = useIconState((s) => s.packages);
|
||||
const icons = useMemo<RenderIconType[]>(() => {
|
||||
return iconPacks.flatMap((pack) =>
|
||||
Object.entries(pack.icons).flatMap(([name, icon]) =>
|
||||
Object.entries(icon.variants).map(([variant]) => ({
|
||||
package: pack.prefix,
|
||||
name: `${pack.prefix}:${name}:${variant}`,
|
||||
tags: icon.tags,
|
||||
category: icon.category,
|
||||
variant: variant
|
||||
}))
|
||||
)
|
||||
);
|
||||
}, [iconPacks]);
|
||||
const filter = useMemo(
|
||||
() =>
|
||||
new Fuse(icons, {
|
||||
threshold: 0.2,
|
||||
keys: ['name', 'tags', 'category', 'variant']
|
||||
}),
|
||||
[icons]
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [debouncedSearchValue] = useDebouncedValue(searchValue, 200);
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [pack, setPack] = useState<string | null>(null);
|
||||
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
icons
|
||||
.filter((i) => (pack !== null ? i.package === pack : true))
|
||||
.map((i) => i.category)
|
||||
)
|
||||
).map((x) =>
|
||||
x === ''
|
||||
? { value: '', label: t`Uncategorized` }
|
||||
: { value: x, label: x }
|
||||
),
|
||||
[icons, pack]
|
||||
);
|
||||
const packs = useMemo(
|
||||
() => iconPacks.map((pack) => ({ value: pack.prefix, label: pack.name })),
|
||||
[iconPacks]
|
||||
);
|
||||
|
||||
const applyFilters = (
|
||||
iconList: RenderIconType[],
|
||||
category: string | null,
|
||||
pack: string | null
|
||||
) => {
|
||||
if (category === null && pack === null) return iconList;
|
||||
return iconList.filter(
|
||||
(i) =>
|
||||
(category !== null ? i.category === category : true) &&
|
||||
(pack !== null ? i.package === pack : true)
|
||||
);
|
||||
};
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!debouncedSearchValue) {
|
||||
return applyFilters(icons, category, pack);
|
||||
}
|
||||
|
||||
const res = filter.search(debouncedSearchValue.trim()).map((r) => r.item);
|
||||
|
||||
return applyFilters(res, category, pack);
|
||||
}, [debouncedSearchValue, filter, category, pack]);
|
||||
|
||||
// Reset category when pack changes and the current category is not available in the new pack
|
||||
useEffect(() => {
|
||||
if (value === null) return;
|
||||
|
||||
if (!categories.find((c) => c.value === category)) {
|
||||
setCategory(null);
|
||||
}
|
||||
}, [pack]);
|
||||
|
||||
const { width, ref } = useElementSize();
|
||||
|
||||
return (
|
||||
<Stack gap={6} ref={ref}>
|
||||
<Group gap={4}>
|
||||
<TextInput
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||
placeholder={t`Search...`}
|
||||
rightSection={
|
||||
searchValue && !definition.required ? (
|
||||
<IconX size="1rem" onClick={() => setSearchValue('')} />
|
||||
) : null
|
||||
}
|
||||
flex={1}
|
||||
/>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(c) => startTransition(() => setCategory(c))}
|
||||
data={categories}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
clearable
|
||||
placeholder={t`Select category`}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={pack}
|
||||
onChange={(c) => startTransition(() => setPack(c))}
|
||||
data={packs}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
clearable
|
||||
placeholder={t`Select pack`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed" ta="center" mt={-4}>
|
||||
<Trans>{filteredIcons.length} icons</Trans>
|
||||
</Text>
|
||||
|
||||
<DropdownList
|
||||
icons={filteredIcons}
|
||||
onChange={onChange}
|
||||
combobox={combobox}
|
||||
value={value}
|
||||
width={width}
|
||||
open={open}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownList({
|
||||
icons,
|
||||
onChange,
|
||||
combobox,
|
||||
value,
|
||||
width,
|
||||
open
|
||||
}: Readonly<{
|
||||
icons: RenderIconType[];
|
||||
onChange: (newVal: string | null) => void;
|
||||
combobox: ComboboxStore;
|
||||
value: string | null;
|
||||
width: number;
|
||||
open: boolean;
|
||||
}>) {
|
||||
// Get the inner width of the dropdown (excluding the scrollbar) by using the outerRef provided by the react-window Grid element
|
||||
const { width: innerWidth, ref: innerRef } = useElementSize();
|
||||
|
||||
const columnCount = Math.floor(innerWidth / 35);
|
||||
const rowCount = columnCount > 0 ? Math.ceil(icons.length / columnCount) : 0;
|
||||
|
||||
const gridRef = useRef<Grid>(null);
|
||||
const hasScrolledToPositionRef = useRef(true);
|
||||
|
||||
// Reset the has already scrolled to position state when the dropdown open state is changed
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasScrolledToPositionRef.current = false;
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [open]);
|
||||
|
||||
// Scroll to the selected icon if not already has scrolled to position
|
||||
useEffect(() => {
|
||||
// Do not scroll if the value is not set, columnCount is not set, the dropdown is not open, or the position has already been scrolled to
|
||||
if (
|
||||
!value ||
|
||||
columnCount === 0 ||
|
||||
hasScrolledToPositionRef.current ||
|
||||
!open
|
||||
)
|
||||
return;
|
||||
|
||||
const iconIdx = icons.findIndex((i) => i.name === value);
|
||||
if (iconIdx === -1) return;
|
||||
|
||||
gridRef.current?.scrollToItem({
|
||||
align: 'start',
|
||||
rowIndex: Math.floor(iconIdx / columnCount)
|
||||
});
|
||||
hasScrolledToPositionRef.current = true;
|
||||
}, [value, columnCount, open]);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
height={200}
|
||||
width={width}
|
||||
rowCount={rowCount}
|
||||
columnCount={columnCount}
|
||||
rowHeight={35}
|
||||
columnWidth={35}
|
||||
itemData={icons}
|
||||
outerRef={innerRef}
|
||||
ref={gridRef}
|
||||
>
|
||||
{({ columnIndex, rowIndex, data, style }) => {
|
||||
const icon = data[rowIndex * columnCount + columnIndex];
|
||||
|
||||
// Grid has empty cells in the last row if the number of icons is not a multiple of columnCount
|
||||
if (icon === undefined) return null;
|
||||
|
||||
const isSelected = value === icon.name;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={icon.name}
|
||||
title={icon.name}
|
||||
onClick={() => {
|
||||
onChange(isSelected ? null : icon.name);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
background: isSelected
|
||||
? 'var(--mantine-color-blue-filled)'
|
||||
: 'unset',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<ApiIcon name={icon.name} size={24} />
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Grid>
|
||||
);
|
||||
}
|
13
src/frontend/src/components/items/ApiIcon.css.ts
Normal file
13
src/frontend/src/components/items/ApiIcon.css.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const icon = style({
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 'normal',
|
||||
fontVariant: 'normal',
|
||||
textTransform: 'none',
|
||||
lineHeight: 1,
|
||||
width: 'fit-content',
|
||||
// Better font rendering
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale'
|
||||
});
|
27
src/frontend/src/components/items/ApiIcon.tsx
Normal file
27
src/frontend/src/components/items/ApiIcon.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useIconState } from '../../states/IconState';
|
||||
import * as classes from './ApiIcon.css';
|
||||
|
||||
type ApiIconProps = {
|
||||
name: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ApiIcon = ({ name: _name, size = 22 }: ApiIconProps) => {
|
||||
const [iconPackage, name, variant] = _name.split(':');
|
||||
const icon = useIconState(
|
||||
(s) => s.packagesMap[iconPackage]?.['icons'][name]?.['variants'][variant]
|
||||
);
|
||||
const unicode = icon ? String.fromCodePoint(parseInt(icon, 16)) : '';
|
||||
|
||||
return (
|
||||
<i
|
||||
className={classes.icon}
|
||||
style={{
|
||||
fontFamily: `inventree-icon-font-${iconPackage}`,
|
||||
fontSize: size
|
||||
}}
|
||||
>
|
||||
{unicode}
|
||||
</i>
|
||||
);
|
||||
};
|
@ -14,6 +14,7 @@ import { identifierString } from '../../functions/conversion';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
|
||||
export type Breadcrumb = {
|
||||
icon?: React.ReactNode;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
@ -69,7 +70,10 @@ export function BreadcrumbList({
|
||||
navigateToLink(breadcrumb.url, navigate, event)
|
||||
}
|
||||
>
|
||||
<Text size="sm">{breadcrumb.name}</Text>
|
||||
<Group gap={4}>
|
||||
{breadcrumb.icon}
|
||||
<Text size="sm">{breadcrumb.name}</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
|
@ -15,7 +15,6 @@ import {
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPoint,
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@ -28,6 +27,7 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/*
|
||||
@ -100,7 +100,12 @@ export default function NavigationTree({
|
||||
let node = {
|
||||
...query.data[ii],
|
||||
children: [],
|
||||
label: query.data[ii].name,
|
||||
label: (
|
||||
<Group gap="xs">
|
||||
<ApiIcon name={query.data[ii].icon} />
|
||||
{query.data[ii].name}
|
||||
</Group>
|
||||
),
|
||||
value: query.data[ii].pk.toString(),
|
||||
selected: query.data[ii].pk === selectedId
|
||||
};
|
||||
@ -157,9 +162,7 @@ export default function NavigationTree({
|
||||
) : (
|
||||
<IconChevronRight />
|
||||
)
|
||||
) : (
|
||||
<IconPoint />
|
||||
)}
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
<Anchor
|
||||
onClick={(event: any) => follow(payload.node, event)}
|
||||
|
@ -7,6 +7,7 @@ import { Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||
|
||||
interface PageDetailInterface {
|
||||
title?: string;
|
||||
icon?: ReactNode;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
detail?: ReactNode;
|
||||
@ -24,6 +25,7 @@ interface PageDetailInterface {
|
||||
*/
|
||||
export function PageDetail({
|
||||
title,
|
||||
icon,
|
||||
subtitle,
|
||||
detail,
|
||||
badges,
|
||||
@ -50,9 +52,12 @@ export function PageDetail({
|
||||
<Stack gap="xs">
|
||||
{title && <StylishText size="lg">{title}</StylishText>}
|
||||
{subtitle && (
|
||||
<Text size="md" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{icon}
|
||||
<Text size="md" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
@ -151,6 +151,7 @@ export function RenderRemoteInstance({
|
||||
export function RenderInlineModel({
|
||||
primary,
|
||||
secondary,
|
||||
prefix,
|
||||
suffix,
|
||||
image,
|
||||
labels,
|
||||
@ -161,6 +162,7 @@ export function RenderInlineModel({
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
showSecondary?: boolean;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
image?: string;
|
||||
labels?: string[];
|
||||
@ -181,6 +183,7 @@ export function RenderInlineModel({
|
||||
return (
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" justify="left" wrap="nowrap">
|
||||
{prefix}
|
||||
{image && <Thumbnail src={image} size={18} />}
|
||||
{url ? (
|
||||
<Anchor href={url} onClick={(event: any) => onClick(event)}>
|
||||
|
@ -4,6 +4,7 @@ import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
|
||||
|
||||
/**
|
||||
@ -60,6 +61,7 @@ export function RenderPartCategory(
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
primary={`${lvl} ${instance.name}`}
|
||||
secondary={instance.description}
|
||||
url={
|
||||
|
@ -3,6 +3,7 @@ import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
|
||||
|
||||
/**
|
||||
@ -16,6 +17,7 @@ export function RenderStockLocation(
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
primary={instance.name}
|
||||
secondary={instance.description}
|
||||
url={
|
||||
@ -36,7 +38,7 @@ export function RenderStockLocationType({
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
// TODO: render location icon here too (ref: #7237)
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
secondary={instance.description + ` (${instance.location_count})`}
|
||||
/>
|
||||
);
|
||||
|
@ -47,6 +47,7 @@ export enum ApiEndpoints {
|
||||
sso_providers = 'auth/providers/',
|
||||
group_list = 'user/group/',
|
||||
owner_list = 'user/owner/',
|
||||
icons = 'icons/',
|
||||
|
||||
// Data import endpoints
|
||||
import_session_list = 'importer/session/',
|
||||
|
@ -132,7 +132,9 @@ export function partCategoryFields(): ApiFormFieldSet {
|
||||
},
|
||||
default_keywords: {},
|
||||
structural: {},
|
||||
icon: {}
|
||||
icon: {
|
||||
field_type: 'icon'
|
||||
}
|
||||
};
|
||||
|
||||
return fields;
|
||||
|
@ -909,7 +909,9 @@ export function stockLocationFields(): ApiFormFieldSet {
|
||||
description: {},
|
||||
structural: {},
|
||||
external: {},
|
||||
custom_icon: {},
|
||||
custom_icon: {
|
||||
field_type: 'icon'
|
||||
},
|
||||
location_type: {}
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { Group, LoadingOverlay, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconCategory,
|
||||
IconDots,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
DeleteItemAction,
|
||||
EditItemAction
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
@ -78,7 +79,13 @@ export default function CategoryDetail() {
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
label: t`Name`,
|
||||
copy: true
|
||||
copy: true,
|
||||
value_formatter: () => (
|
||||
<Group gap="xs">
|
||||
{category.icon && <ApiIcon name={category.icon} />}
|
||||
{category.name}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
@ -267,7 +274,8 @@ export default function CategoryDetail() {
|
||||
{ name: t`Parts`, url: '/part' },
|
||||
...(category.path ?? []).map((c: any) => ({
|
||||
name: c.name,
|
||||
url: getDetailUrl(ModelType.partcategory, c.pk)
|
||||
url: getDetailUrl(ModelType.partcategory, c.pk),
|
||||
icon: c.icon ? <ApiIcon name={c.icon} /> : undefined
|
||||
}))
|
||||
],
|
||||
[category]
|
||||
@ -296,6 +304,7 @@ export default function CategoryDetail() {
|
||||
<PageDetail
|
||||
title={t`Part Category`}
|
||||
subtitle={category?.name}
|
||||
icon={category?.icon && <ApiIcon name={category?.icon} />}
|
||||
breadcrumbs={breadcrumbs}
|
||||
breadcrumbAction={() => {
|
||||
setTreeOpen(true);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { Group, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
IconInfoCircle,
|
||||
@ -23,6 +23,7 @@ import {
|
||||
UnlinkBarcodeAction,
|
||||
ViewBarcodeAction
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
@ -85,7 +86,13 @@ export default function Stock() {
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
label: t`Name`,
|
||||
copy: true
|
||||
copy: true,
|
||||
value_formatter: () => (
|
||||
<Group gap="xs">
|
||||
{location.icon && <ApiIcon name={location.icon} />}
|
||||
{location.name}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
@ -352,7 +359,8 @@ export default function Stock() {
|
||||
{ name: t`Stock`, url: '/stock' },
|
||||
...(location.path ?? []).map((l: any) => ({
|
||||
name: l.name,
|
||||
url: getDetailUrl(ModelType.stocklocation, l.pk)
|
||||
url: getDetailUrl(ModelType.stocklocation, l.pk),
|
||||
icon: l.icon ? <ApiIcon name={l.icon} /> : undefined
|
||||
}))
|
||||
],
|
||||
[location]
|
||||
@ -378,6 +386,7 @@ export default function Stock() {
|
||||
<PageDetail
|
||||
title={t`Stock Items`}
|
||||
subtitle={location?.name}
|
||||
icon={location?.icon && <ApiIcon name={location?.icon} />}
|
||||
actions={locationActions}
|
||||
breadcrumbs={breadcrumbs}
|
||||
breadcrumbAction={() => {
|
||||
|
68
src/frontend/src/states/IconState.tsx
Normal file
68
src/frontend/src/states/IconState.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from './ApiState';
|
||||
import { useLocalState } from './LocalState';
|
||||
|
||||
type IconPackage = {
|
||||
name: string;
|
||||
prefix: string;
|
||||
fonts: Record<string, string>;
|
||||
icons: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
variants: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
type IconState = {
|
||||
hasLoaded: boolean;
|
||||
packages: IconPackage[];
|
||||
packagesMap: Record<string, IconPackage>;
|
||||
fetchIcons: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useIconState = create<IconState>()((set, get) => ({
|
||||
hasLoaded: false,
|
||||
packages: [],
|
||||
packagesMap: {},
|
||||
fetchIcons: async () => {
|
||||
if (get().hasLoaded) return;
|
||||
|
||||
const host = useLocalState.getState().host;
|
||||
|
||||
const packs = await api.get(apiUrl(ApiEndpoints.icons));
|
||||
|
||||
await Promise.all(
|
||||
packs.data.map(async (pack: any) => {
|
||||
const fontName = `inventree-icon-font-${pack.prefix}`;
|
||||
const src = Object.entries(pack.fonts as Record<string, string>)
|
||||
.map(
|
||||
([format, url]) =>
|
||||
`url(${
|
||||
url.startsWith('/') ? host + url : url
|
||||
}) format("${format}")`
|
||||
)
|
||||
.join(',\n');
|
||||
const font = new FontFace(fontName, src + ';');
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
|
||||
return font;
|
||||
})
|
||||
);
|
||||
|
||||
set({
|
||||
hasLoaded: true,
|
||||
packages: packs.data,
|
||||
packagesMap: Object.fromEntries(
|
||||
packs.data.map((pack: any) => [pack.prefix, pack])
|
||||
)
|
||||
});
|
||||
}
|
||||
}));
|
@ -1,5 +1,6 @@
|
||||
import { setApiDefaults } from '../App';
|
||||
import { useServerApiState } from './ApiState';
|
||||
import { useIconState } from './IconState';
|
||||
import { useGlobalSettingsState, useUserSettingsState } from './SettingsState';
|
||||
import { useGlobalStatusState } from './StatusState';
|
||||
import { useUserState } from './UserState';
|
||||
@ -138,4 +139,5 @@ export function fetchGlobalStates() {
|
||||
useUserSettingsState.getState().fetchSettings();
|
||||
useGlobalSettingsState.getState().fetchSettings();
|
||||
useGlobalStatusState.getState().fetchStatus();
|
||||
useIconState.getState().fetchIcons();
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -32,7 +34,13 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
switchable: false
|
||||
switchable: false,
|
||||
render: (record: any) => (
|
||||
<Group gap="xs">
|
||||
{record.icon && <ApiIcon name={record.icon} />}
|
||||
{record.name}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
{
|
||||
|
@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import {
|
||||
@ -25,7 +26,9 @@ export default function LocationTypesTable() {
|
||||
return {
|
||||
name: {},
|
||||
description: {},
|
||||
icon: {}
|
||||
icon: {
|
||||
field_type: 'icon'
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -55,6 +58,12 @@ export default function LocationTypesTable() {
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'icon',
|
||||
title: t`Icon`,
|
||||
sortable: true,
|
||||
render: (value: any) => <ApiIcon name={value.icon} />
|
||||
},
|
||||
{
|
||||
accessor: 'name',
|
||||
title: t`Name`,
|
||||
@ -64,11 +73,6 @@ export default function LocationTypesTable() {
|
||||
accessor: 'description',
|
||||
title: t`Description`
|
||||
},
|
||||
{
|
||||
accessor: 'icon',
|
||||
title: t`Icon`,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'location_count',
|
||||
sortable: true
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -69,7 +71,13 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
|
||||
return [
|
||||
{
|
||||
accessor: 'name',
|
||||
switchable: false
|
||||
switchable: false,
|
||||
render: (record: any) => (
|
||||
<Group gap="xs">
|
||||
{record.icon && <ApiIcon name={record.icon} />}
|
||||
{record.name}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
{
|
||||
|
@ -335,6 +335,13 @@
|
||||
"@babel/plugin-transform-modules-commonjs" "^7.24.7"
|
||||
"@babel/plugin-transform-typescript" "^7.24.7"
|
||||
|
||||
"@babel/runtime@^7.0.0":
|
||||
version "7.24.8"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e"
|
||||
integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
|
||||
version "7.24.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e"
|
||||
@ -2634,6 +2641,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.8":
|
||||
version "1.8.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3"
|
||||
integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^18.3.3":
|
||||
version "18.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f"
|
||||
@ -3845,6 +3859,11 @@ function-bind@^1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||
|
||||
fuse.js@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
|
||||
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
|
||||
|
||||
gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
@ -4538,6 +4557,11 @@ media-query-parser@^2.0.2:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
memoize-one@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
|
||||
@ -5511,6 +5535,14 @@ react-transition-group@4.4.5, react-transition-group@^4.3.0, react-transition-gr
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-window@^1.8.10:
|
||||
version "1.8.10"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03"
|
||||
integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^18.2.0, react@^18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
|
||||
|
Loading…
Reference in New Issue
Block a user