mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 03:55:41 +00:00
[PUI] Dashboard refactor (#8278)
* Refactor plugin components into <RemoteComponent /> * Clean up footer * Allow BuildOrder list to be sorted by 'outstanding' * Fix model name * Update BuildOrderTable filter * Add StockItemTable column * Working towards new dashboard * Cleanup unused imports * Updates: Now rendering some custom widgets * Define icons for model types * Add icon * Cleanup / refactor / delete - Complete transfer of files into new structure * Follow link for query count widgets * Add some more widgets to the library * Remove old dashboard link in header * Remove feedback widget * Bump API version * Remove test widget * Rename "Home" -> "Dashboard" * Add some more widgets * Pass 'editable' property through to widgets * Cleanup * Add drawer for selecting new widgets * Allow different layouts per user on the same machine * Fixes * Add ability to *remove* widgets * Add helpful button * Add a keyboard shortcut * Refactoring * Add backend code for serving custom dashboard items * Load dashboard items from plugins * Tweak for dashboard item API query - Refetch if user changes - Tweak "loaded" value - Prevent refetchOnMount * Add message if no dashboard widgets are displayed * Refactoring main navigation menu - Group into sections - Cleanup / consolidation - General refactoring * Remove playground * Add backend field for storing dashboard layout * Add extra type definitions for UseInstance * Manual labels for builtin dashboard items - Otherwise they will change with translation locale * Shorten labels for more plugins * Adjust DashboardMenu * Reduce stored data * Add widget filter by text * Remove back-end settings * Update playwright tests for dashboard * Updated tests * Refactor backend API for fetching plugin features * Further fixes for back-end code * More back-end fixes * Refactor frontend: - Custom panels - Custom dashboard items * Further backend fixes * Yet more backend fixes - Improve error handling * Fix for custom plugin settings rendering * Enable plugin panels for part index and stock index pages * Cleanup * Fix nav menu * Update typing * Helper func to return all plugin settings as a dict * Update API version date * Fix for UseInstancea * typing fix * Tweak layout callbacks * Pass query parameters through to navigation functions * Improve custom query display * Add "news" widget * Ensure links are prepended with base URL on receipt * Update NewsWidget * Bug fix * Refactor template editor tests * Refactor unit testing for test_ui_panels * Unit test for dashboard item API endpoint * Update comment * Adjust playwright tests * More playwright fixes * Hide barcode scanning options if disabled * Tweak dashboard widget * Fix custom panel title * Update documentation around UIMixin class * Cleanup * Additional docs * Add icon def for 'error' ModelType * Add error boundary to TemplateEditor component * Fix so that it works with template editors and previews again * Tweak error messages * API unit test fixes * Unit test fix * More unit test fixes * Playwright test tweaks * Adjust error messages
This commit is contained in:
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 276
|
||||
INVENTREE_API_VERSION = 277
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v277 - 2024-11-01 : https://github.com/inventree/InvenTree/pull/8278
|
||||
- Allow build order list to be filtered by "outstanding" (alias for "active")
|
||||
|
||||
v276 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8403
|
||||
- Adds 'destination' field to the PurchaseOrder model and API endpoints
|
||||
|
||||
|
@ -40,6 +40,9 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
|
||||
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
||||
|
||||
# 'outstanding' is an alias for 'active' here
|
||||
outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active')
|
||||
|
||||
def filter_active(self, queryset, name, value):
|
||||
"""Filter the queryset to either include or exclude orders which are active."""
|
||||
if str2bool(value):
|
||||
|
@ -451,6 +451,10 @@ class BuildTest(BuildAPITest):
|
||||
# Now, let's delete each build output individually via the API
|
||||
outputs = bo.build_outputs.all()
|
||||
|
||||
# Assert that each output is currently in production
|
||||
for output in outputs:
|
||||
self.assertTrue(output.is_building)
|
||||
|
||||
delete_url = reverse('api-build-output-delete', kwargs={'pk': 1})
|
||||
|
||||
response = self.post(
|
||||
|
@ -895,7 +895,9 @@ class BaseInvenTreeSetting(models.Model):
|
||||
except ValidationError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
raise ValidationError({'value': _('Invalid value')})
|
||||
raise ValidationError({
|
||||
'value': _('Value does not pass validation checks')
|
||||
})
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""Ensure that the key:value pair is unique. In addition to the base validators, this ensures that the 'key' is unique, using a case-insensitive comparison.
|
||||
|
@ -196,7 +196,9 @@ class PurchaseOrderMixin:
|
||||
"""Return the annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related('supplier', 'lines')
|
||||
queryset = queryset.prefetch_related(
|
||||
'supplier', 'project_code', 'lines', 'responsible'
|
||||
)
|
||||
|
||||
queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
@ -671,7 +673,9 @@ class SalesOrderMixin:
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related('customer', 'lines')
|
||||
queryset = queryset.prefetch_related(
|
||||
'customer', 'responsible', 'project_code', 'lines'
|
||||
)
|
||||
|
||||
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
@ -1244,7 +1248,9 @@ class ReturnOrderMixin:
|
||||
"""Return annotated queryset for this endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related('customer')
|
||||
queryset = queryset.prefetch_related(
|
||||
'customer', 'lines', 'project_code', 'responsible'
|
||||
)
|
||||
|
||||
queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
|
@ -105,3 +105,32 @@ class SettingsMixin:
|
||||
return PluginSetting.check_all_settings(
|
||||
settings_definition=self.settings, plugin=self.plugin_config()
|
||||
)
|
||||
|
||||
def get_settings_dict(self) -> dict:
|
||||
"""Return a dictionary of all settings for this plugin.
|
||||
|
||||
- For each setting, return <key>: <value> pair.
|
||||
- If the setting is not defined, return the default value (if defined).
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of all settings for this plugin
|
||||
"""
|
||||
from plugin.models import PluginSetting
|
||||
|
||||
keys = self.settings.keys()
|
||||
|
||||
settings = PluginSetting.objects.filter(
|
||||
plugin=self.plugin_config(), key__in=keys
|
||||
)
|
||||
|
||||
settings_dict = {}
|
||||
|
||||
for setting in settings:
|
||||
settings_dict[setting.key] = setting.value
|
||||
|
||||
# Add any missing settings
|
||||
for key in keys:
|
||||
if key not in settings_dict:
|
||||
settings_dict[key] = self.settings[key].get('default')
|
||||
|
||||
return settings_dict
|
||||
|
@ -13,47 +13,6 @@ from InvenTree.exceptions import log_error
|
||||
from plugin import registry
|
||||
|
||||
|
||||
class PluginPanelList(APIView):
|
||||
"""API endpoint for listing all available plugin panels."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = UIPluginSerializers.PluginPanelSerializer
|
||||
|
||||
@extend_schema(
|
||||
responses={200: UIPluginSerializers.PluginPanelSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
"""Show available plugin panels."""
|
||||
target_model = request.query_params.get('target_model', None)
|
||||
target_id = request.query_params.get('target_id', None)
|
||||
|
||||
panels = []
|
||||
|
||||
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
|
||||
# Extract all plugins from the registry which provide custom panels
|
||||
for _plugin in registry.with_mixin('ui', active=True):
|
||||
try:
|
||||
# Allow plugins to fill this data out
|
||||
plugin_panels = _plugin.get_ui_panels(
|
||||
target_model, target_id, request
|
||||
)
|
||||
|
||||
if plugin_panels and type(plugin_panels) is list:
|
||||
for panel in plugin_panels:
|
||||
panel['plugin'] = _plugin.slug
|
||||
|
||||
# TODO: Validate each panel before inserting
|
||||
panels.append(panel)
|
||||
except Exception:
|
||||
# Custom panels could not load
|
||||
# Log the error and continue
|
||||
log_error(f'{_plugin.slug}.get_ui_panels')
|
||||
|
||||
return Response(
|
||||
UIPluginSerializers.PluginPanelSerializer(panels, many=True).data
|
||||
)
|
||||
|
||||
|
||||
class PluginUIFeatureList(APIView):
|
||||
"""API endpoint for listing all available plugin ui features."""
|
||||
|
||||
@ -71,24 +30,49 @@ class PluginUIFeatureList(APIView):
|
||||
# Extract all plugins from the registry which provide custom ui features
|
||||
for _plugin in registry.with_mixin('ui', active=True):
|
||||
# Allow plugins to fill this data out
|
||||
plugin_features = _plugin.get_ui_features(
|
||||
feature, request.query_params, request
|
||||
)
|
||||
|
||||
try:
|
||||
plugin_features = _plugin.get_ui_features(
|
||||
feature, request.query_params, request
|
||||
)
|
||||
except Exception:
|
||||
# Custom features could not load for this plugin
|
||||
# Log the error and continue
|
||||
log_error(f'{_plugin.slug}.get_ui_features')
|
||||
continue
|
||||
|
||||
if plugin_features and type(plugin_features) is list:
|
||||
for _feature in plugin_features:
|
||||
features.append(_feature)
|
||||
try:
|
||||
# Ensure that the required fields are present
|
||||
_feature['plugin_name'] = _plugin.slug
|
||||
_feature['feature_type'] = str(feature)
|
||||
|
||||
return Response(
|
||||
UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data
|
||||
)
|
||||
# Ensure base fields are strings
|
||||
for field in ['key', 'title', 'description', 'source']:
|
||||
if field in _feature:
|
||||
_feature[field] = str(_feature[field])
|
||||
|
||||
# Add the feature to the list (serialize)
|
||||
features.append(
|
||||
UIPluginSerializers.PluginUIFeatureSerializer(
|
||||
_feature, many=False
|
||||
).data
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Custom features could not load
|
||||
# Log the error and continue
|
||||
log_error(f'{_plugin.slug}.get_ui_features')
|
||||
continue
|
||||
|
||||
return Response(features)
|
||||
|
||||
|
||||
ui_plugins_api_urls = [
|
||||
path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'),
|
||||
path(
|
||||
'features/<str:feature>/',
|
||||
PluginUIFeatureList.as_view(),
|
||||
name='api-plugin-ui-feature-list',
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -11,43 +11,59 @@ from rest_framework.request import Request
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class CustomPanel(TypedDict):
|
||||
"""Type definition for a custom panel.
|
||||
|
||||
Attributes:
|
||||
name: The name of the panel (required, used as a DOM identifier).
|
||||
label: The label of the panel (required, human readable).
|
||||
icon: The icon of the panel (optional, must be a valid icon identifier).
|
||||
content: The content of the panel (optional, raw HTML).
|
||||
context: Optional context data (dict / JSON) which will be passed to the front-end rendering function
|
||||
source: The source of the panel (optional, path to a JavaScript file).
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
icon: str
|
||||
content: str
|
||||
context: dict
|
||||
source: str
|
||||
|
||||
|
||||
FeatureType = Literal['template_editor', 'template_preview']
|
||||
# List of supported feature types
|
||||
FeatureType = Literal[
|
||||
'dashboard', # Custom dashboard items
|
||||
'panel', # Custom panels
|
||||
'template_editor', # Custom template editor
|
||||
'template_preview', # Custom template preview
|
||||
]
|
||||
|
||||
|
||||
class UIFeature(TypedDict):
|
||||
"""Base type definition for a ui feature.
|
||||
|
||||
Attributes:
|
||||
key: The key of the feature (required, must be a unique identifier)
|
||||
title: The title of the feature (required, human readable)
|
||||
description: The long-form description of the feature (optional, human readable)
|
||||
icon: The icon of the feature (optional, must be a valid icon identifier)
|
||||
feature_type: The feature type (required, see documentation for all available types)
|
||||
options: Feature options (required, see documentation for all available options for each type)
|
||||
source: The source of the feature (required, path to a JavaScript file).
|
||||
context: Additional context data to be passed to the rendering function (optional, dict)
|
||||
source: The source of the feature (required, path to a JavaScript file, with optional function name).
|
||||
"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
description: str
|
||||
icon: str
|
||||
feature_type: FeatureType
|
||||
options: dict
|
||||
context: dict
|
||||
source: str
|
||||
|
||||
|
||||
class CustomPanelOptions(TypedDict):
|
||||
"""Options type definition for a custom panel.
|
||||
|
||||
Attributes:
|
||||
icon: The icon of the panel (optional, must be a valid icon identifier).
|
||||
"""
|
||||
|
||||
|
||||
class CustomDashboardItemOptions(TypedDict):
|
||||
"""Options type definition for a custom dashboard item.
|
||||
|
||||
Attributes:
|
||||
width: The minimum width of the dashboard item (integer, defaults to 2)
|
||||
height: The minimum height of the dashboard item (integer, defaults to 2)
|
||||
"""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
class UserInterfaceMixin:
|
||||
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
|
||||
|
||||
@ -65,48 +81,85 @@ class UserInterfaceMixin:
|
||||
super().__init__()
|
||||
self.add_mixin('ui', True, __class__) # type: ignore
|
||||
|
||||
def get_ui_panels(
|
||||
self, instance_type: str, instance_id: int, request: Request, **kwargs
|
||||
) -> list[CustomPanel]:
|
||||
"""Return a list of custom panels to be injected into the UI.
|
||||
|
||||
Args:
|
||||
instance_type: The type of object being viewed (e.g. 'part')
|
||||
instance_id: The ID of the object being viewed (e.g. 123)
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom panels to be injected into the UI
|
||||
|
||||
- The returned list should contain a dict for each custom panel to be injected into the UI:
|
||||
- The following keys can be specified:
|
||||
{
|
||||
'name': 'panel_name', # The name of the panel (required, must be unique)
|
||||
'label': 'Panel Title', # The title of the panel (required, human readable)
|
||||
'icon': 'icon-name', # Icon name (optional, must be a valid icon identifier)
|
||||
'content': '<p>Panel content</p>', # HTML content to be rendered in the panel (optional)
|
||||
'context': {'key': 'value'}, # Context data to be passed to the front-end rendering function (optional)
|
||||
'source': 'static/plugin/panel.js', # Path to a JavaScript file to be loaded (optional)
|
||||
}
|
||||
|
||||
- Either 'source' or 'content' must be provided
|
||||
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
||||
def get_ui_features(
|
||||
self, feature_type: FeatureType, context: dict, request: Request
|
||||
self, feature_type: FeatureType, context: dict, request: Request, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom features to be injected into the UI.
|
||||
|
||||
Arguments:
|
||||
feature_type: The type of feature being requested
|
||||
context: Additional context data provided by the UI
|
||||
context: Additional context data provided by the UI (query parameters)
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom UIFeature dicts to be injected into the UI
|
||||
|
||||
"""
|
||||
feature_map = {
|
||||
'dashboard': self.get_ui_dashboard_items,
|
||||
'panel': self.get_ui_panels,
|
||||
'template_editor': self.get_ui_template_editors,
|
||||
'template_preview': self.get_ui_template_previews,
|
||||
}
|
||||
|
||||
if feature_type in feature_map:
|
||||
return feature_map[feature_type](request, context, **kwargs)
|
||||
else:
|
||||
logger.warning(f'Invalid feature type: {feature_type}')
|
||||
return []
|
||||
|
||||
def get_ui_panels(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom panels to be injected into the UI.
|
||||
|
||||
Args:
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom panels to be injected into the UI
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
||||
def get_ui_dashboard_items(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom dashboard items to be injected into the UI.
|
||||
|
||||
Args:
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom dashboard items to be injected into the UI
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
||||
def get_ui_template_editors(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom template editors to be injected into the UI.
|
||||
|
||||
Args:
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom template editors to be injected into the UI
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
||||
def get_ui_template_previews(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom template previews to be injected into the UI.
|
||||
|
||||
Args:
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom template previews to be injected into the UI
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
@ -5,68 +5,60 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class PluginPanelSerializer(serializers.Serializer):
|
||||
"""Serializer for a plugin panel."""
|
||||
|
||||
class Meta:
|
||||
"""Meta for serializer."""
|
||||
|
||||
fields = [
|
||||
'plugin',
|
||||
'name',
|
||||
'label',
|
||||
# Following fields are optional
|
||||
'icon',
|
||||
'content',
|
||||
'context',
|
||||
'source',
|
||||
]
|
||||
|
||||
# Required fields
|
||||
plugin = serializers.CharField(
|
||||
label=_('Plugin Key'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
name = serializers.CharField(
|
||||
label=_('Panel Name'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
label = serializers.CharField(
|
||||
label=_('Panel Title'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
icon = serializers.CharField(
|
||||
label=_('Panel Icon'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
content = serializers.CharField(
|
||||
label=_('Panel Content (HTML)'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
context = serializers.JSONField(
|
||||
label=_('Panel Context (JSON)'), required=False, allow_null=True, default=None
|
||||
)
|
||||
|
||||
source = serializers.CharField(
|
||||
label=_('Panel Source (javascript)'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
|
||||
class PluginUIFeatureSerializer(serializers.Serializer):
|
||||
"""Serializer for a plugin ui feature."""
|
||||
|
||||
class Meta:
|
||||
"""Meta for serializer."""
|
||||
|
||||
fields = ['feature_type', 'options', 'source']
|
||||
fields = [
|
||||
'plugin_name',
|
||||
'feature_type',
|
||||
'key',
|
||||
'title',
|
||||
'description',
|
||||
'icon',
|
||||
'options',
|
||||
'context',
|
||||
'source',
|
||||
]
|
||||
|
||||
# Required fields
|
||||
|
||||
# The name of the plugin that provides this feature
|
||||
plugin_name = serializers.CharField(
|
||||
label=_('Plugin Name'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
feature_type = serializers.CharField(
|
||||
label=_('Feature Type'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
options = serializers.DictField(label=_('Feature Options'), required=True)
|
||||
# Item key to be used in the UI - this should be a DOM identifier and is not user facing
|
||||
key = serializers.CharField(
|
||||
label=_('Feature Label'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
# Title to be used in the UI - this is user facing (and should be human readable)
|
||||
title = serializers.CharField(
|
||||
label=_('Feature Title'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
# Long-form description of the feature (optional)
|
||||
description = serializers.CharField(
|
||||
label=_('Feature Description'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
# Optional icon
|
||||
icon = serializers.CharField(
|
||||
label=_('Feature Icon'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
# Additional options, specific to the particular UI feature
|
||||
options = serializers.DictField(label=_('Feature Options'), default=None)
|
||||
|
||||
# Server side context, supplied to the client side for rendering
|
||||
context = serializers.DictField(label=_('Feature Context'), default=None)
|
||||
|
||||
source = serializers.CharField(
|
||||
label=_('Feature Source (javascript)'), required=True, allow_blank=False
|
||||
|
@ -33,7 +33,60 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
plugins = registry.with_mixin('ui')
|
||||
self.assertGreater(len(plugins), 0)
|
||||
|
||||
def test_panels(self):
|
||||
def test_ui_dashboard_items(self):
|
||||
"""Test that the sample UI plugin provides custom dashboard items."""
|
||||
# Ensure the user has superuser status
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'dashboard'})
|
||||
|
||||
response = self.get(url)
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
for item in response.data:
|
||||
self.assertEqual(item['plugin_name'], 'sampleui')
|
||||
|
||||
self.assertEqual(response.data[0]['key'], 'broken-dashboard-item')
|
||||
self.assertEqual(response.data[0]['title'], 'Broken Dashboard Item')
|
||||
self.assertEqual(response.data[0]['source'], '/this/does/not/exist.js')
|
||||
|
||||
self.assertEqual(response.data[1]['key'], 'sample-dashboard-item')
|
||||
self.assertEqual(response.data[1]['title'], 'Sample Dashboard Item')
|
||||
self.assertEqual(
|
||||
response.data[1]['source'],
|
||||
'/static/plugins/sampleui/sample_dashboard_item.js',
|
||||
)
|
||||
|
||||
self.assertEqual(response.data[2]['key'], 'dynamic-dashboard-item')
|
||||
self.assertEqual(response.data[2]['title'], 'Context Dashboard Item')
|
||||
self.assertEqual(
|
||||
response.data[2]['source'],
|
||||
'/static/plugins/sampleui/sample_dashboard_item.js:renderContextItem',
|
||||
)
|
||||
|
||||
self.assertEqual(response.data[3]['key'], 'admin-dashboard-item')
|
||||
self.assertEqual(response.data[3]['title'], 'Admin Dashboard Item')
|
||||
self.assertEqual(
|
||||
response.data[3]['source'],
|
||||
'/static/plugins/sampleui/admin_dashboard_item.js',
|
||||
)
|
||||
|
||||
# Additional options and context data should be passed through to the client
|
||||
self.assertDictEqual(response.data[3]['options'], {'width': 4, 'height': 2})
|
||||
|
||||
self.assertDictEqual(
|
||||
response.data[3]['context'], {'secret-key': 'this-is-a-secret'}
|
||||
)
|
||||
|
||||
# Remove superuser status - the 'admin-dashboard-item' should disappear
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
|
||||
response = self.get(url)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_ui_panels(self):
|
||||
"""Test that the sample UI plugin provides custom panels."""
|
||||
from part.models import Part
|
||||
|
||||
@ -45,7 +98,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
_part.active = True
|
||||
_part.save()
|
||||
|
||||
url = reverse('api-plugin-panel-list')
|
||||
url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'panel'})
|
||||
|
||||
query_data = {'target_model': 'part', 'target_id': _part.pk}
|
||||
|
||||
@ -59,7 +112,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should be 4 active panels for the part by default
|
||||
self.assertEqual(4, len(response.data))
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
_part.active = False
|
||||
_part.save()
|
||||
@ -74,23 +127,27 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should still be 3 panels
|
||||
self.assertEqual(3, len(response.data))
|
||||
# There should still be 2 panels
|
||||
self.assertEqual(2, len(response.data))
|
||||
|
||||
# Check for the correct panel names
|
||||
self.assertEqual(response.data[0]['name'], 'sample_panel')
|
||||
self.assertIn('content', response.data[0])
|
||||
self.assertNotIn('source', response.data[0])
|
||||
for panel in response.data:
|
||||
self.assertEqual(panel['plugin_name'], 'sampleui')
|
||||
self.assertEqual(panel['feature_type'], 'panel')
|
||||
|
||||
self.assertEqual(response.data[1]['name'], 'broken_panel')
|
||||
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
|
||||
self.assertNotIn('content', response.data[1])
|
||||
self.assertEqual(response.data[0]['key'], 'broken-panel')
|
||||
self.assertEqual(response.data[0]['title'], 'Broken Panel')
|
||||
self.assertEqual(response.data[0]['source'], '/this/does/not/exist.js')
|
||||
|
||||
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
|
||||
self.assertEqual(response.data[1]['key'], 'dynamic-panel')
|
||||
self.assertEqual(response.data[1]['title'], 'Dynamic Panel')
|
||||
self.assertEqual(
|
||||
response.data[2]['source'], '/static/plugins/sampleui/sample_panel.js'
|
||||
response.data[1]['source'], '/static/plugins/sampleui/sample_panel.js'
|
||||
)
|
||||
self.assertNotIn('content', response.data[2])
|
||||
|
||||
ctx = response.data[1]['context']
|
||||
|
||||
for k in ['version', 'plugin_version', 'random', 'time']:
|
||||
self.assertIn(k, ctx)
|
||||
|
||||
# Next, disable the global setting for UI integration
|
||||
InvenTreeSetting.set_setting(
|
||||
@ -105,8 +162,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
# Set the setting back to True for subsequent tests
|
||||
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
|
||||
|
||||
def test_ui_features(self):
|
||||
"""Test that the sample UI plugin provides custom features."""
|
||||
def test_ui_template_editors(self):
|
||||
"""Test that the sample UI plugin provides template editor features."""
|
||||
template_editor_url = reverse(
|
||||
'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'}
|
||||
)
|
||||
@ -120,30 +177,39 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
'template_model': 'part',
|
||||
}
|
||||
|
||||
# Request custom template editor information
|
||||
# Request custom label template editor information
|
||||
response = self.get(template_editor_url, data=query_data_label)
|
||||
self.assertEqual(1, len(response.data))
|
||||
|
||||
data = response.data[0]
|
||||
|
||||
for k, v in {
|
||||
'plugin_name': 'sampleui',
|
||||
'key': 'sample-template-editor',
|
||||
'title': 'Sample Template Editor',
|
||||
'source': '/static/plugins/sampleui/sample_template.js:getTemplateEditor',
|
||||
}.items():
|
||||
self.assertEqual(data[k], v)
|
||||
|
||||
# Request custom report template editor information
|
||||
response = self.get(template_editor_url, data=query_data_report)
|
||||
self.assertEqual(0, len(response.data))
|
||||
|
||||
# Request custom report template preview information
|
||||
response = self.get(template_preview_url, data=query_data_report)
|
||||
self.assertEqual(1, len(response.data))
|
||||
|
||||
# Check for the correct feature details here
|
||||
self.assertEqual(response.data[0]['feature_type'], 'template_preview')
|
||||
self.assertDictEqual(
|
||||
response.data[0]['options'],
|
||||
{
|
||||
'key': 'sample-template-preview',
|
||||
'title': 'Sample Template Preview',
|
||||
'icon': 'category',
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data[0]['source'],
|
||||
'/static/plugin/sample_template.js:getTemplatePreview',
|
||||
)
|
||||
data = response.data[0]
|
||||
|
||||
for k, v in {
|
||||
'plugin_name': 'sampleui',
|
||||
'feature_type': 'template_preview',
|
||||
'key': 'sample-template-preview',
|
||||
'title': 'Sample Template Preview',
|
||||
'context': None,
|
||||
'source': '/static/plugins/sampleui/sample_preview.js:getTemplatePreview',
|
||||
}.items():
|
||||
self.assertEqual(data[k], v)
|
||||
|
||||
# Next, disable the global setting for UI integration
|
||||
InvenTreeSetting.set_setting(
|
||||
|
@ -1,30 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<h4>Custom Plugin Panel</h4>
|
||||
|
||||
<p>
|
||||
This content has been rendered by a custom plugin, and will be displayed for any "part" instance
|
||||
(as long as the plugin is enabled).
|
||||
This content has been rendered on the server, using the django templating system.
|
||||
</p>
|
||||
|
||||
<h5>Part Details</h5>
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<th>Part Name</th>
|
||||
<td>{{ part.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part Description</th>
|
||||
<td>{{ part.description }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part Category</th>
|
||||
<td>{{ part.category.pathstring }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part IPN</th>
|
||||
<td>{% if part.IPN %}{{ part.IPN }}{% else %}<i>No IPN specified</i>{% endif %}</td>
|
||||
</tr>
|
||||
</table>
|
@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from InvenTree.version import INVENTREE_SW_VERSION
|
||||
from part.models import Part
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.helpers import render_template, render_text
|
||||
from plugin.mixins import SettingsMixin, UserInterfaceMixin
|
||||
|
||||
|
||||
@ -19,7 +18,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
SLUG = 'sampleui'
|
||||
TITLE = 'Sample User Interface Plugin'
|
||||
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
|
||||
VERSION = '1.1'
|
||||
VERSION = '2.0'
|
||||
|
||||
ADMIN_SOURCE = 'ui_settings.js'
|
||||
|
||||
@ -50,39 +49,22 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
},
|
||||
}
|
||||
|
||||
def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs):
|
||||
def get_ui_panels(self, request, context, **kwargs):
|
||||
"""Return a list of custom panels to be injected into the UI."""
|
||||
panels = []
|
||||
context = context or {}
|
||||
|
||||
# First, add a custom panel which will appear on every type of page
|
||||
# This panel will contain a simple message
|
||||
|
||||
content = render_text(
|
||||
"""
|
||||
This is a <i>sample panel</i> which appears on every page.
|
||||
It renders a simple string of <b>HTML</b> content.
|
||||
|
||||
<br>
|
||||
<h5>Instance Details:</h5>
|
||||
<ul>
|
||||
<li>Instance Type: {{ instance_type }}</li>
|
||||
<li>Instance ID: {{ instance_id }}</li>
|
||||
</ul>
|
||||
""",
|
||||
context={'instance_type': instance_type, 'instance_id': instance_id},
|
||||
)
|
||||
|
||||
panels.append({
|
||||
'name': 'sample_panel',
|
||||
'label': 'Sample Panel',
|
||||
'content': content,
|
||||
})
|
||||
target_model = context.get('target_model', None)
|
||||
target_id = context.get('target_id', None)
|
||||
|
||||
# A broken panel which tries to load a non-existent JS file
|
||||
if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
|
||||
if target_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
|
||||
panels.append({
|
||||
'name': 'broken_panel',
|
||||
'label': 'Broken Panel',
|
||||
'key': 'broken-panel',
|
||||
'title': 'Broken Panel',
|
||||
'source': '/this/does/not/exist.js',
|
||||
})
|
||||
|
||||
@ -90,85 +72,131 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
# Note that we additionally provide some "context" data to the front-end render function
|
||||
if self.get_setting('ENABLE_DYNAMIC_PANEL'):
|
||||
panels.append({
|
||||
'name': 'dynamic_panel',
|
||||
'label': 'Dynamic Part Panel',
|
||||
'key': 'dynamic-panel',
|
||||
'title': 'Dynamic Panel',
|
||||
'source': self.plugin_static_file('sample_panel.js'),
|
||||
'icon': 'part',
|
||||
'context': {
|
||||
'version': INVENTREE_SW_VERSION,
|
||||
'plugin_version': self.VERSION,
|
||||
'random': random.randint(1, 100),
|
||||
'time': time.time(),
|
||||
},
|
||||
'icon': 'part',
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'part' page
|
||||
# Note that this content is rendered from a template file,
|
||||
# using the django templating system
|
||||
if self.get_setting('ENABLE_PART_PANELS') and instance_type == 'part':
|
||||
if self.get_setting('ENABLE_PART_PANELS') and target_model == 'part':
|
||||
try:
|
||||
part = Part.objects.get(pk=instance_id)
|
||||
part = Part.objects.get(pk=target_id)
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
part = None
|
||||
|
||||
# Note: This panel will *only* be available if the part is active
|
||||
if part and part.active:
|
||||
content = render_template(
|
||||
self, 'uidemo/custom_part_panel.html', context={'part': part}
|
||||
)
|
||||
|
||||
panels.append({
|
||||
'name': 'part_panel',
|
||||
'label': 'Part Panel',
|
||||
'content': content,
|
||||
})
|
||||
panels.append({
|
||||
'key': 'part-panel',
|
||||
'title': _('Part Panel'),
|
||||
'source': self.plugin_static_file('sample_panel.js:renderPartPanel'),
|
||||
'icon': 'part',
|
||||
'context': {'part_name': part.name if part else ''},
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'purchaseorder' page
|
||||
if (
|
||||
self.get_setting('ENABLE_PURCHASE_ORDER_PANELS')
|
||||
and instance_type == 'purchaseorder'
|
||||
if target_model == 'purchaseorder' and self.get_setting(
|
||||
'ENABLE_PURCHASE_ORDER_PANELS'
|
||||
):
|
||||
panels.append({
|
||||
'name': 'purchase_order_panel',
|
||||
'label': 'Purchase Order Panel',
|
||||
'content': 'This is a custom panel which appears on the <b>Purchase Order</b> view page.',
|
||||
'key': 'purchase_order_panel',
|
||||
'title': 'Purchase Order Panel',
|
||||
'source': self.plugin_static_file('sample_panel.js:renderPoPanel'),
|
||||
})
|
||||
|
||||
# Admin panel - only visible to admin users
|
||||
if request.user.is_superuser:
|
||||
panels.append({
|
||||
'key': 'admin-panel',
|
||||
'title': 'Admin Panel',
|
||||
'source': self.plugin_static_file(
|
||||
'sample_panel.js:renderAdminOnlyPanel'
|
||||
),
|
||||
})
|
||||
|
||||
return panels
|
||||
|
||||
def get_ui_features(self, feature_type, context, request):
|
||||
"""Return a list of custom features to be injected into the UI."""
|
||||
if (
|
||||
feature_type == 'template_editor'
|
||||
and context.get('template_type') == 'labeltemplate'
|
||||
):
|
||||
return [
|
||||
{
|
||||
'feature_type': 'template_editor',
|
||||
'options': {
|
||||
'key': 'sample-template-editor',
|
||||
'title': 'Sample Template Editor',
|
||||
'icon': 'keywords',
|
||||
},
|
||||
'source': '/static/plugin/sample_template.js:getTemplateEditor',
|
||||
}
|
||||
]
|
||||
def get_ui_dashboard_items(self, request, context, **kwargs):
|
||||
"""Return a list of custom dashboard items."""
|
||||
items = [
|
||||
{
|
||||
'key': 'broken-dashboard-item',
|
||||
'title': _('Broken Dashboard Item'),
|
||||
'description': _(
|
||||
'This is a broken dashboard item - it will not render!'
|
||||
),
|
||||
'source': '/this/does/not/exist.js',
|
||||
},
|
||||
{
|
||||
'key': 'sample-dashboard-item',
|
||||
'title': _('Sample Dashboard Item'),
|
||||
'description': _(
|
||||
'This is a sample dashboard item. It renders a simple string of HTML content.'
|
||||
),
|
||||
'source': self.plugin_static_file('sample_dashboard_item.js'),
|
||||
},
|
||||
{
|
||||
'key': 'dynamic-dashboard-item',
|
||||
'title': _('Context Dashboard Item'),
|
||||
'description': 'A dashboard item which passes context data from the server',
|
||||
'source': self.plugin_static_file(
|
||||
'sample_dashboard_item.js:renderContextItem'
|
||||
),
|
||||
'context': {'foo': 'bar', 'hello': 'world'},
|
||||
'options': {'width': 3, 'height': 2},
|
||||
},
|
||||
]
|
||||
|
||||
if feature_type == 'template_preview':
|
||||
# Admin item - only visible to users with superuser access
|
||||
if request.user.is_superuser:
|
||||
items.append({
|
||||
'key': 'admin-dashboard-item',
|
||||
'title': _('Admin Dashboard Item'),
|
||||
'description': _('This is an admin-only dashboard item.'),
|
||||
'source': self.plugin_static_file('admin_dashboard_item.js'),
|
||||
'options': {'width': 4, 'height': 2},
|
||||
'context': {'secret-key': 'this-is-a-secret'},
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
def get_ui_template_editors(self, request, context, **kwargs):
|
||||
"""Return a list of custom template editors."""
|
||||
# If the context is a label template, return a custom template editor
|
||||
if context.get('template_type') == 'labeltemplate':
|
||||
return [
|
||||
{
|
||||
'feature_type': 'template_preview',
|
||||
'options': {
|
||||
'key': 'sample-template-preview',
|
||||
'title': 'Sample Template Preview',
|
||||
'icon': 'category',
|
||||
},
|
||||
'source': '/static/plugin/sample_template.js:getTemplatePreview',
|
||||
'key': 'sample-template-editor',
|
||||
'title': 'Sample Template Editor',
|
||||
'icon': 'keywords',
|
||||
'source': self.plugin_static_file(
|
||||
'sample_template.js:getTemplateEditor'
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
def get_ui_template_previews(self, request, context, **kwargs):
|
||||
"""Return a list of custom template previews."""
|
||||
return [
|
||||
{
|
||||
'key': 'sample-template-preview',
|
||||
'title': 'Sample Template Preview',
|
||||
'icon': 'category',
|
||||
'source': self.plugin_static_file(
|
||||
'sample_preview.js:getTemplatePreview'
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
def get_admin_context(self) -> dict:
|
||||
"""Return custom context data which can be rendered in the admin panel."""
|
||||
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
|
||||
|
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A sample dashboard item plugin for InvenTree.
|
||||
*
|
||||
* - This is a *very basic* example.
|
||||
* - In practice, you would want to use React / Mantine / etc to render more complex UI elements.
|
||||
*/
|
||||
|
||||
export function renderDashboardItem(target, data) {
|
||||
|
||||
if (!target) {
|
||||
console.error("No target provided to renderDashboardItem");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Admin Item</h4>
|
||||
<hr>
|
||||
<p>Hello there, admin user!</p>
|
||||
`;
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* A sample dashboard item plugin for InvenTree.
|
||||
*
|
||||
* - This is a *very basic* example.
|
||||
* - In practice, you would want to use React / Mantine / etc to render more complex UI elements.
|
||||
*/
|
||||
|
||||
export function renderDashboardItem(target, data) {
|
||||
|
||||
if (!target) {
|
||||
console.error("No target provided to renderDashboardItem");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Sample Dashboard Item</h4>
|
||||
<hr>
|
||||
<p>Hello world! This is a sample dashboard item loaded by the plugin system.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
export function renderContextItem(target, data) {
|
||||
|
||||
if (!target) {
|
||||
console.error("No target provided to renderContextItem");
|
||||
return;
|
||||
}
|
||||
|
||||
let context = data?.context ?? {};
|
||||
|
||||
let ctxString = '';
|
||||
|
||||
for (let key in context) {
|
||||
ctxString += `<tr><td>${key}</td><td>${context[key]}</td></tr>`;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Sample Context Item</h4>
|
||||
<hr>
|
||||
<p>Hello world! This is a sample context item loaded by the plugin system.</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Item</th><th>Value</th></tr>
|
||||
${ctxString}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
@ -43,6 +43,55 @@ export function renderPanel(target, data) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render a panel on a Part detail page
|
||||
*/
|
||||
export function renderPartPanel(target, data) {
|
||||
|
||||
if (!target) {
|
||||
console.error("No target provided to renderPartPanel");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Part Detail Panel</h4>
|
||||
<hr>
|
||||
<p>This is a custom panel for a Part detail page</p>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Render a panel on a PurchaseOrder detail page
|
||||
*/
|
||||
export function renderPoPanel(target, data) {
|
||||
if (!target) {
|
||||
console.error("No target provided to renderPoPanel");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Order Reference: ${data.instance?.reference}</h4>
|
||||
<hr>
|
||||
<p>This is a custom panel for a PurchaseOrder detail page</p>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render a panel that is only visible to admin users
|
||||
*/
|
||||
export function renderAdminOnlyPanel(target, data) {
|
||||
if (!target) {
|
||||
console.error("No target provided to renderAdminOnlyPanel");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `Hello Admin user! This panel is only visible to admin users.`;
|
||||
}
|
||||
|
||||
|
||||
// Dynamically hide the panel based on the provided context
|
||||
export function isPanelHidden(context) {
|
||||
|
||||
|
@ -0,0 +1,12 @@
|
||||
export function getTemplatePreview({ featureContext, pluginContext }) {
|
||||
const { ref } = featureContext;
|
||||
console.log("Template preview feature was called with", featureContext, pluginContext);
|
||||
|
||||
featureContext.registerHandlers({
|
||||
updatePreview: (...args) => {
|
||||
console.log("updatePreview", args);
|
||||
}
|
||||
});
|
||||
|
||||
ref.innerHTML = "<h1>Hello world</h1>";
|
||||
}
|
@ -18,16 +18,3 @@ export function getTemplateEditor({ featureContext, pluginContext }) {
|
||||
ref.innerHTML = "";
|
||||
ref.appendChild(t);
|
||||
}
|
||||
|
||||
export function getTemplatePreview({ featureContext, pluginContext }) {
|
||||
const { ref } = featureContext;
|
||||
console.log("Template preview feature was called with", featureContext, pluginContext);
|
||||
|
||||
featureContext.registerHandlers({
|
||||
updatePreview: (...args) => {
|
||||
console.log("updatePreview", args);
|
||||
}
|
||||
});
|
||||
|
||||
ref.innerHTML = "<h1>Hello world</h1>";
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { StatisticItem } from './items/DashboardItem';
|
||||
import { ErrorItem } from './items/ErrorItem';
|
||||
|
||||
export function DashboardItemProxy({
|
||||
id,
|
||||
text,
|
||||
url,
|
||||
params,
|
||||
autoupdate = true
|
||||
}: Readonly<{
|
||||
id: string;
|
||||
text: string;
|
||||
url: ApiEndpoints;
|
||||
params: any;
|
||||
autoupdate: boolean;
|
||||
}>) {
|
||||
function fetchData() {
|
||||
return api
|
||||
.get(`${apiUrl(url)}?search=&offset=0&limit=25`, { params: params })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
const { isLoading, error, data, isFetching } = useQuery({
|
||||
queryKey: [`dash_${id}`],
|
||||
queryFn: fetchData,
|
||||
refetchOnWindowFocus: autoupdate
|
||||
});
|
||||
const [dashData, setDashData] = useState({ title: t`Title`, value: '000' });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDashData({ title: text, value: data.count });
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (error != null) return <ErrorItem id={id} error={error} />;
|
||||
return (
|
||||
<div key={id}>
|
||||
<StatisticItem
|
||||
id={id}
|
||||
data={dashData}
|
||||
isLoading={isLoading || isFetching}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -12,12 +12,12 @@ export function ScanButton() {
|
||||
onClick={() =>
|
||||
openContextModal({
|
||||
modal: 'qr',
|
||||
title: t`Scan QR code`,
|
||||
title: t`Scan Barcode`,
|
||||
innerProps: {}
|
||||
})
|
||||
}
|
||||
variant="transparent"
|
||||
title={t`Open QR code scanner`}
|
||||
title={t`Open Barcode Scanner`}
|
||||
>
|
||||
<IconQrcode />
|
||||
</ActionIcon>
|
||||
|
328
src/frontend/src/components/dashboard/DashboardLayout.tsx
Normal file
328
src/frontend/src/components/dashboard/DashboardLayout.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Card, Center, Divider, Loader, Text } from '@mantine/core';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Layout, Responsive, WidthProvider } from 'react-grid-layout';
|
||||
|
||||
import { useDashboardItems } from '../../hooks/UseDashboardItems';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import DashboardMenu from './DashboardMenu';
|
||||
import DashboardWidget, { DashboardWidgetProps } from './DashboardWidget';
|
||||
import DashboardWidgetDrawer from './DashboardWidgetDrawer';
|
||||
|
||||
const ReactGridLayout = WidthProvider(Responsive);
|
||||
|
||||
/**
|
||||
* Save the dashboard layout to local storage
|
||||
*/
|
||||
function saveDashboardLayout(layouts: any, userId: number | undefined): void {
|
||||
let reducedLayouts: any = {};
|
||||
|
||||
// Reduce the layouts to exclude default attributes from the dataset
|
||||
Object.keys(layouts).forEach((key) => {
|
||||
reducedLayouts[key] = layouts[key].map((item: Layout) => {
|
||||
return {
|
||||
...item,
|
||||
moved: item.moved ? true : undefined,
|
||||
static: item.static ? true : undefined
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const data = JSON.stringify(reducedLayouts);
|
||||
|
||||
if (userId) {
|
||||
localStorage?.setItem(`dashboard-layout-${userId}`, data);
|
||||
}
|
||||
|
||||
localStorage?.setItem('dashboard-layout', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the dashboard layout from local storage
|
||||
*/
|
||||
function loadDashboardLayout(
|
||||
userId: number | undefined
|
||||
): Record<string, Layout[]> {
|
||||
let layout = userId && localStorage?.getItem(`dashboard-layout-${userId}`);
|
||||
|
||||
if (!layout) {
|
||||
// Fallback to global layout
|
||||
layout = localStorage?.getItem('dashboard-layout');
|
||||
}
|
||||
|
||||
if (layout) {
|
||||
return JSON.parse(layout);
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the list of selected widgets to local storage
|
||||
*/
|
||||
function saveDashboardWidgets(
|
||||
widgets: string[],
|
||||
userId: number | undefined
|
||||
): void {
|
||||
const data = JSON.stringify(widgets);
|
||||
|
||||
if (userId) {
|
||||
localStorage?.setItem(`dashboard-widgets-${userId}`, data);
|
||||
}
|
||||
|
||||
localStorage?.setItem('dashboard-widgets', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the list of selected widgets from local storage
|
||||
*/
|
||||
function loadDashboardWidgets(userId: number | undefined): string[] {
|
||||
let widgets = userId && localStorage?.getItem(`dashboard-widgets-${userId}`);
|
||||
|
||||
if (!widgets) {
|
||||
// Fallback to global widget list
|
||||
widgets = localStorage?.getItem('dashboard-widgets');
|
||||
}
|
||||
|
||||
if (widgets) {
|
||||
return JSON.parse(widgets);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardLayout({}: {}) {
|
||||
const user = useUserState();
|
||||
|
||||
// Dashboard layout definition
|
||||
const [layouts, setLayouts] = useState({});
|
||||
|
||||
// Dashboard widget selection
|
||||
const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]);
|
||||
|
||||
const [editing, setEditing] = useDisclosure(false);
|
||||
const [removing, setRemoving] = useDisclosure(false);
|
||||
|
||||
const [
|
||||
widgetDrawerOpened,
|
||||
{ open: openWidgetDrawer, close: closeWidgetDrawer }
|
||||
] = useDisclosure(false);
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Keyboard shortcut for editing the dashboard layout
|
||||
useHotkeys([
|
||||
[
|
||||
'mod+E',
|
||||
() => {
|
||||
setEditing.toggle();
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
// Load available widgets
|
||||
const availableWidgets = useDashboardItems();
|
||||
|
||||
const widgetLabels = useMemo(() => {
|
||||
return widgets.map((widget: DashboardWidgetProps) => widget.label);
|
||||
}, [widgets]);
|
||||
|
||||
// Save the selected widgets to local storage when the selection changes
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
saveDashboardWidgets(widgetLabels, user.userId());
|
||||
}
|
||||
}, [widgetLabels]);
|
||||
|
||||
/**
|
||||
* Callback function to add a new widget to the dashboard
|
||||
*/
|
||||
const addWidget = useCallback(
|
||||
(widget: string) => {
|
||||
let newWidget = availableWidgets.items.find(
|
||||
(wid) => wid.label === widget
|
||||
);
|
||||
|
||||
if (newWidget) {
|
||||
setWidgets([...widgets, newWidget]);
|
||||
}
|
||||
|
||||
// Update the layouts to include the new widget (and enforce initial size)
|
||||
let _layouts: any = { ...layouts };
|
||||
|
||||
Object.keys(_layouts).forEach((key) => {
|
||||
_layouts[key] = updateLayoutForWidget(_layouts[key], widgets, true);
|
||||
});
|
||||
|
||||
setLayouts(_layouts);
|
||||
},
|
||||
[availableWidgets.items, widgets, layouts]
|
||||
);
|
||||
|
||||
/**
|
||||
* Callback function to remove a widget from the dashboard
|
||||
*/
|
||||
const removeWidget = useCallback(
|
||||
(widget: string) => {
|
||||
// Remove the widget from the list
|
||||
setWidgets(widgets.filter((item) => item.label !== widget));
|
||||
|
||||
// Remove the widget from the layout
|
||||
let _layouts: any = { ...layouts };
|
||||
|
||||
Object.keys(_layouts).forEach((key) => {
|
||||
_layouts[key] = _layouts[key].filter(
|
||||
(item: Layout) => item.i !== widget
|
||||
);
|
||||
});
|
||||
|
||||
setLayouts(_layouts);
|
||||
},
|
||||
[widgets, layouts]
|
||||
);
|
||||
|
||||
// When the layout is rendered, ensure that the widget attributes are observed
|
||||
const updateLayoutForWidget = useCallback(
|
||||
(layout: any[], widgets: any[], overrideSize: boolean) => {
|
||||
return layout.map((item: Layout): Layout => {
|
||||
// Find the matching widget
|
||||
let widget = widgets.find(
|
||||
(widget: DashboardWidgetProps) => widget.label === item.i
|
||||
);
|
||||
|
||||
const minH = widget?.minHeight ?? 2;
|
||||
const minW = widget?.minWidth ?? 1;
|
||||
|
||||
let w = Math.max(item.w ?? 1, minW);
|
||||
let h = Math.max(item.h ?? 1, minH);
|
||||
|
||||
if (overrideSize) {
|
||||
w = minW;
|
||||
h = minH;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
w: w,
|
||||
h: h,
|
||||
minH: minH,
|
||||
minW: minW
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Rebuild layout when the widget list changes
|
||||
useEffect(() => {
|
||||
onLayoutChange({}, layouts);
|
||||
}, [widgets]);
|
||||
|
||||
const onLayoutChange = useCallback(
|
||||
(layout: any, newLayouts: any) => {
|
||||
// Reconstruct layouts based on the widget requirements
|
||||
Object.keys(newLayouts).forEach((key) => {
|
||||
newLayouts[key] = updateLayoutForWidget(
|
||||
newLayouts[key],
|
||||
widgets,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
if (layouts && loaded && availableWidgets.loaded) {
|
||||
saveDashboardLayout(newLayouts, user.userId());
|
||||
setLayouts(newLayouts);
|
||||
}
|
||||
},
|
||||
[loaded, widgets, availableWidgets.loaded]
|
||||
);
|
||||
|
||||
// Load the dashboard layout from local storage
|
||||
useEffect(() => {
|
||||
if (availableWidgets.loaded) {
|
||||
const initialLayouts = loadDashboardLayout(user.userId());
|
||||
const initialWidgetLabels = loadDashboardWidgets(user.userId());
|
||||
|
||||
setLayouts(initialLayouts);
|
||||
setWidgets(
|
||||
availableWidgets.items.filter((widget) =>
|
||||
initialWidgetLabels.includes(widget.label)
|
||||
)
|
||||
);
|
||||
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [availableWidgets.loaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardWidgetDrawer
|
||||
opened={widgetDrawerOpened}
|
||||
onClose={closeWidgetDrawer}
|
||||
onAddWidget={addWidget}
|
||||
currentWidgets={widgetLabels}
|
||||
/>
|
||||
<DashboardMenu
|
||||
onAddWidget={openWidgetDrawer}
|
||||
onStartEdit={setEditing.open}
|
||||
onStartRemove={setRemoving.open}
|
||||
onAcceptLayout={() => {
|
||||
setEditing.close();
|
||||
setRemoving.close();
|
||||
}}
|
||||
editing={editing}
|
||||
removing={removing}
|
||||
/>
|
||||
<Divider p="xs" />
|
||||
{layouts && loaded && availableWidgets.loaded ? (
|
||||
<>
|
||||
{widgetLabels.length == 0 ? (
|
||||
<Center>
|
||||
<Card shadow="xs" padding="xl" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
color="blue"
|
||||
title={t`No Widgets Selected`}
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
<Text>{t`Use the menu to add widgets to the dashboard`}</Text>
|
||||
</Alert>
|
||||
</Card>
|
||||
</Center>
|
||||
) : (
|
||||
<ReactGridLayout
|
||||
className="dashboard-layout"
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={64}
|
||||
layouts={layouts}
|
||||
onLayoutChange={onLayoutChange}
|
||||
compactType={'vertical'}
|
||||
isDraggable={editing}
|
||||
isResizable={editing}
|
||||
margin={[10, 10]}
|
||||
containerPadding={[0, 0]}
|
||||
resizeHandles={['ne', 'se', 'sw', 'nw']}
|
||||
>
|
||||
{widgets.map((item: DashboardWidgetProps) => {
|
||||
return DashboardWidget({
|
||||
item: item,
|
||||
editing: editing,
|
||||
removing: removing,
|
||||
onRemove: () => {
|
||||
removeWidget(item.label);
|
||||
}
|
||||
});
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Center>
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
135
src/frontend/src/components/dashboard/DashboardMenu.tsx
Normal file
135
src/frontend/src/components/dashboard/DashboardMenu.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
Paper,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconDotsVertical,
|
||||
IconLayout2,
|
||||
IconLayoutGridAdd,
|
||||
IconLayoutGridRemove
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useInstanceName from '../../hooks/UseInstanceName';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/**
|
||||
* A menu for editing the dashboard layout
|
||||
*/
|
||||
export default function DashboardMenu({
|
||||
editing,
|
||||
removing,
|
||||
onAddWidget,
|
||||
onStartEdit,
|
||||
onStartRemove,
|
||||
onAcceptLayout
|
||||
}: {
|
||||
editing: boolean;
|
||||
removing: boolean;
|
||||
onAddWidget: () => void;
|
||||
onStartEdit: () => void;
|
||||
onStartRemove: () => void;
|
||||
onAcceptLayout: () => void;
|
||||
}) {
|
||||
const user = useUserState();
|
||||
const instanceName = useInstanceName();
|
||||
|
||||
const title = useMemo(() => {
|
||||
const username = user.username();
|
||||
|
||||
return (
|
||||
<StylishText size="lg">{`${instanceName} - ${username}`}</StylishText>
|
||||
);
|
||||
}, [user, instanceName]);
|
||||
|
||||
return (
|
||||
<Paper p="sm" shadow="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{title}
|
||||
|
||||
<Group justify="right" wrap="nowrap">
|
||||
{(editing || removing) && (
|
||||
<Tooltip label={t`Accept Layout`} onClick={onAcceptLayout}>
|
||||
<ActionIcon
|
||||
aria-label={'dashboard-accept-layout'}
|
||||
color="green"
|
||||
variant="transparent"
|
||||
>
|
||||
<IconCircleCheck />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={200}
|
||||
openDelay={100}
|
||||
closeDelay={400}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
color="red"
|
||||
position="bottom-start"
|
||||
processing
|
||||
disabled={!editing}
|
||||
>
|
||||
<ActionIcon variant="transparent" aria-label="dashboard-menu">
|
||||
<IconDotsVertical />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Trans>Dashboard</Trans>
|
||||
</Menu.Label>
|
||||
|
||||
{!editing && !removing && (
|
||||
<Menu.Item
|
||||
leftSection={<IconLayout2 color="blue" size={14} />}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
<Trans>Edit Layout</Trans>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!editing && !removing && (
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutGridAdd color="green" size={14} />}
|
||||
onClick={onAddWidget}
|
||||
>
|
||||
<Trans>Add Widget</Trans>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!editing && !removing && (
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutGridRemove color="red" size={14} />}
|
||||
onClick={onStartRemove}
|
||||
>
|
||||
<Trans>Remove Widgets</Trans>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{(editing || removing) && (
|
||||
<Menu.Item
|
||||
leftSection={<IconCircleCheck color="green" size={14} />}
|
||||
onClick={onAcceptLayout}
|
||||
>
|
||||
<Trans>Accept Layout</Trans>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
84
src/frontend/src/components/dashboard/DashboardWidget.tsx
Normal file
84
src/frontend/src/components/dashboard/DashboardWidget.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Box, Group, Overlay, Paper, Tooltip } from '@mantine/core';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
|
||||
import { Boundary } from '../Boundary';
|
||||
|
||||
/**
|
||||
* Dashboard widget properties.
|
||||
*
|
||||
* @param title The title of the widget
|
||||
* @param visible A function that returns whether the widget should be visible
|
||||
* @param render A function that renders the widget
|
||||
*/
|
||||
export interface DashboardWidgetProps {
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
render: () => JSX.Element;
|
||||
visible?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for a dashboard widget.
|
||||
*/
|
||||
export default function DashboardWidget({
|
||||
item,
|
||||
editing,
|
||||
removing,
|
||||
onRemove
|
||||
}: {
|
||||
item: DashboardWidgetProps;
|
||||
editing: boolean;
|
||||
removing: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
// TODO: Implement visibility check
|
||||
// if (!props?.visible?.() == false) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// TODO: Add button to remove widget (if "editing")
|
||||
|
||||
return (
|
||||
<Paper withBorder key={item.label} shadow="sm" p="xs">
|
||||
<Boundary label={`dashboard-widget-${item.label}`}>
|
||||
<Box
|
||||
key={`dashboard-widget-${item.label}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '0px',
|
||||
margin: '0px',
|
||||
overflowY: 'hidden'
|
||||
}}
|
||||
>
|
||||
{item.render()}
|
||||
</Box>
|
||||
{removing && (
|
||||
<Overlay color="black" opacity={0.7} zIndex={1000}>
|
||||
{removing && (
|
||||
<Group justify="right">
|
||||
<Tooltip
|
||||
label={t`Remove this widget from the dashboard`}
|
||||
position="bottom"
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={`remove-dashboard-item-${item.label}`}
|
||||
variant="filled"
|
||||
color="red"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Overlay>
|
||||
)}
|
||||
</Boundary>
|
||||
</Paper>
|
||||
);
|
||||
}
|
130
src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx
Normal file
130
src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconBackspace, IconLayoutGridAdd } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useDashboardItems } from '../../hooks/UseDashboardItems';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/**
|
||||
* Drawer allowing the user to add new widgets to the dashboard.
|
||||
*/
|
||||
export default function DashboardWidgetDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
onAddWidget,
|
||||
currentWidgets
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAddWidget: (widget: string) => void;
|
||||
currentWidgets: string[];
|
||||
}) {
|
||||
// Load available widgets
|
||||
const availableWidgets = useDashboardItems();
|
||||
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [filterText] = useDebouncedValue(filter, 500);
|
||||
|
||||
// Memoize available (not currently used) widgets
|
||||
const unusedWidgets = useMemo(() => {
|
||||
return (
|
||||
availableWidgets.items.filter(
|
||||
(widget) => !currentWidgets.includes(widget.label)
|
||||
) ?? []
|
||||
);
|
||||
}, [availableWidgets.items, currentWidgets]);
|
||||
|
||||
// Filter widgets based on search text
|
||||
const filteredWidgets = useMemo(() => {
|
||||
let words = filterText.trim().toLowerCase().split(' ');
|
||||
|
||||
return unusedWidgets.filter((widget) => {
|
||||
return words.every((word) =>
|
||||
widget.title.toLowerCase().includes(word.trim())
|
||||
);
|
||||
});
|
||||
}, [unusedWidgets, filterText]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
position="right"
|
||||
size="50%"
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<StylishText size="lg">Add Dashboard Widgets</StylishText>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Divider />
|
||||
<TextInput
|
||||
aria-label="dashboard-widgets-filter-input"
|
||||
placeholder={t`Filter dashboard widgets`}
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.currentTarget.value)}
|
||||
rightSection={
|
||||
filter && (
|
||||
<IconBackspace
|
||||
aria-label="dashboard-widgets-filter-clear"
|
||||
color="red"
|
||||
onClick={() => setFilter('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
styles={{ root: { width: '100%' } }}
|
||||
/>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
{filteredWidgets.map((widget) => (
|
||||
<Table.Tr key={widget.label}>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
position="left"
|
||||
label={t`Add this widget to the dashboard`}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={`add-widget-${widget.label}`}
|
||||
variant="transparent"
|
||||
color="green"
|
||||
onClick={() => {
|
||||
onAddWidget(widget.label);
|
||||
}}
|
||||
>
|
||||
<IconLayoutGridAdd></IconLayoutGridAdd>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text>{widget.title}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{widget.description}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{unusedWidgets.length === 0 && (
|
||||
<Alert color="blue" title={t`No Widgets Available`}>
|
||||
<Text>{t`There are no more widgets available for the dashboard`}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
180
src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx
Normal file
180
src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { DashboardWidgetProps } from './DashboardWidget';
|
||||
import ColorToggleDashboardWidget from './widgets/ColorToggleWidget';
|
||||
import GetStartedWidget from './widgets/GetStartedWidget';
|
||||
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
|
||||
import NewsWidget from './widgets/NewsWidget';
|
||||
import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns A list of built-in dashboard widgets which display the number of results for a particular query
|
||||
*/
|
||||
export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
return [
|
||||
QueryCountDashboardWidget({
|
||||
label: 'sub-prt',
|
||||
title: t`Subscribed Parts`,
|
||||
description: t`Show the number of parts which you have subscribed to`,
|
||||
modelType: ModelType.part,
|
||||
params: { starred: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
label: 'sub-cat',
|
||||
title: t`Subscribed Categories`,
|
||||
description: t`Show the number of part categories which you have subscribed to`,
|
||||
modelType: ModelType.partcategory,
|
||||
params: { starred: true }
|
||||
}),
|
||||
// TODO: 'latest parts'
|
||||
// TODO: 'BOM waiting validation'
|
||||
// TODO: 'recently updated stock'
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Low Stock`,
|
||||
label: 'low-stk',
|
||||
description: t`Show the number of parts which are low on stock`,
|
||||
modelType: ModelType.part,
|
||||
params: { low_stock: true, active: true }
|
||||
}),
|
||||
// TODO: Required for build orders
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Expired Stock Items`,
|
||||
label: 'exp-stk',
|
||||
description: t`Show the number of stock items which have expired`,
|
||||
modelType: ModelType.stockitem,
|
||||
params: { expired: true }
|
||||
// TODO: Hide if expiry is disabled
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Stale Stock Items`,
|
||||
label: 'stl-stk',
|
||||
description: t`Show the number of stock items which are stale`,
|
||||
modelType: ModelType.stockitem,
|
||||
params: { stale: true }
|
||||
// TODO: Hide if expiry is disabled
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Active Build Orders`,
|
||||
label: 'act-bo',
|
||||
description: t`Show the number of build orders which are currently active`,
|
||||
modelType: ModelType.build,
|
||||
params: { outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Overdue Build Orders`,
|
||||
label: 'ovr-bo',
|
||||
description: t`Show the number of build orders which are overdue`,
|
||||
modelType: ModelType.build,
|
||||
params: { overdue: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Assigned Build Orders`,
|
||||
label: 'asn-bo',
|
||||
description: t`Show the number of build orders which are assigned to you`,
|
||||
modelType: ModelType.build,
|
||||
params: { assigned_to_me: true, outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Active Sales Orders`,
|
||||
label: 'act-so',
|
||||
description: t`Show the number of sales orders which are currently active`,
|
||||
modelType: ModelType.salesorder,
|
||||
params: { outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Overdue Sales Orders`,
|
||||
label: 'ovr-so',
|
||||
description: t`Show the number of sales orders which are overdue`,
|
||||
modelType: ModelType.salesorder,
|
||||
params: { overdue: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Assigned Sales Orders`,
|
||||
label: 'asn-so',
|
||||
description: t`Show the number of sales orders which are assigned to you`,
|
||||
modelType: ModelType.salesorder,
|
||||
params: { assigned_to_me: true, outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Active Purchase Orders`,
|
||||
label: 'act-po',
|
||||
description: t`Show the number of purchase orders which are currently active`,
|
||||
modelType: ModelType.purchaseorder,
|
||||
params: { outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Overdue Purchase Orders`,
|
||||
label: 'ovr-po',
|
||||
description: t`Show the number of purchase orders which are overdue`,
|
||||
modelType: ModelType.purchaseorder,
|
||||
params: { overdue: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Assigned Purchase Orders`,
|
||||
label: 'asn-po',
|
||||
description: t`Show the number of purchase orders which are assigned to you`,
|
||||
modelType: ModelType.purchaseorder,
|
||||
params: { assigned_to_me: true, outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Active Return Orders`,
|
||||
label: 'act-ro',
|
||||
description: t`Show the number of return orders which are currently active`,
|
||||
modelType: ModelType.returnorder,
|
||||
params: { outstanding: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Overdue Return Orders`,
|
||||
label: 'ovr-ro',
|
||||
description: t`Show the number of return orders which are overdue`,
|
||||
modelType: ModelType.returnorder,
|
||||
params: { overdue: true }
|
||||
}),
|
||||
QueryCountDashboardWidget({
|
||||
title: t`Assigned Return Orders`,
|
||||
label: 'asn-ro',
|
||||
description: t`Show the number of return orders which are assigned to you`,
|
||||
modelType: ModelType.returnorder,
|
||||
params: { assigned_to_me: true, outstanding: true }
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
return [
|
||||
{
|
||||
label: 'gstart',
|
||||
title: t`Getting Started`,
|
||||
description: t`Getting started with InvenTree`,
|
||||
minWidth: 5,
|
||||
minHeight: 4,
|
||||
render: () => <GetStartedWidget />
|
||||
},
|
||||
{
|
||||
label: 'news',
|
||||
title: t`News Updates`,
|
||||
description: t`The latest news from InvenTree`,
|
||||
minWidth: 5,
|
||||
minHeight: 4,
|
||||
render: () => <NewsWidget />
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
|
||||
return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns A list of built-in dashboard widgets
|
||||
*/
|
||||
export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
|
||||
return [
|
||||
...BuiltinQueryCountWidgets(),
|
||||
...BuiltinGettingStartedWidgets(),
|
||||
...BuiltinSettingsWidgets()
|
||||
];
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group } from '@mantine/core';
|
||||
|
||||
import { ColorToggle } from '../../items/ColorToggle';
|
||||
import { StylishText } from '../../items/StylishText';
|
||||
import { DashboardWidgetProps } from '../DashboardWidget';
|
||||
|
||||
function ColorToggleWidget(title: string) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<StylishText size="lg">{title}</StylishText>
|
||||
<ColorToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ColorToggleDashboardWidget(): DashboardWidgetProps {
|
||||
const title = t`Change Color Mode`;
|
||||
|
||||
return {
|
||||
label: 'clr',
|
||||
title: title,
|
||||
description: t`Change the color mode of the user interface`,
|
||||
minHeight: 1,
|
||||
minWidth: 2,
|
||||
render: () => ColorToggleWidget(title)
|
||||
};
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DocumentationLinks } from '../../../defaults/links';
|
||||
import { GettingStartedCarousel } from '../../items/GettingStartedCarousel';
|
||||
import { MenuLinkItem } from '../../items/MenuLinks';
|
||||
import { StylishText } from '../../items/StylishText';
|
||||
|
||||
export default function GetStartedWidget() {
|
||||
const docLinks: MenuLinkItem[] = useMemo(() => DocumentationLinks(), []);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<StylishText size="xl">{t`Getting Started`}</StylishText>
|
||||
<GettingStartedCarousel items={docLinks} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
|
||||
import { LanguageSelect } from '../../items/LanguageSelect';
|
||||
import { StylishText } from '../../items/StylishText';
|
||||
import { DashboardWidgetProps } from '../DashboardWidget';
|
||||
|
||||
function LanguageSelectWidget(title: string) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<StylishText size="lg">{title}</StylishText>
|
||||
<LanguageSelect width={140} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LanguageSelectDashboardWidget(): DashboardWidgetProps {
|
||||
const title = t`Change Language`;
|
||||
|
||||
return {
|
||||
label: 'lngsel',
|
||||
title: title,
|
||||
description: t`Change the language of the user interface`,
|
||||
minHeight: 1,
|
||||
minWidth: 2,
|
||||
render: () => LanguageSelectWidget(title)
|
||||
};
|
||||
}
|
143
src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
Normal file
143
src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Container,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconMailCheck } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import { formatDate } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { StylishText } from '../../items/StylishText';
|
||||
|
||||
/**
|
||||
* Render a link to an external news item
|
||||
*/
|
||||
function NewsLink({ item }: { item: any }) {
|
||||
let link: string = item.link;
|
||||
|
||||
if (link && link.startsWith('/')) {
|
||||
link = 'https://inventree.org' + link;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Anchor href={link} target="_blank">
|
||||
{item.title}
|
||||
</Anchor>
|
||||
);
|
||||
} else {
|
||||
return <Text>{item.title}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
function NewsItem({
|
||||
item,
|
||||
onMarkRead
|
||||
}: {
|
||||
item: any;
|
||||
onMarkRead: (id: number) => void;
|
||||
}) {
|
||||
const date: string = item.published?.split(' ')[0] ?? '';
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>{formatDate(date)}</Table.Td>
|
||||
<Table.Td>
|
||||
<NewsLink item={item} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={t`Mark as read`}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="green"
|
||||
variant="transparent"
|
||||
onClick={() => onMarkRead(item.pk)}
|
||||
>
|
||||
<IconMailCheck />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget which displays a list of news items on the dashboard
|
||||
*/
|
||||
export default function NewsWidget() {
|
||||
const user = useUserState();
|
||||
|
||||
const newsItems = useQuery({
|
||||
queryKey: ['news-items'],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.news), {
|
||||
params: {
|
||||
read: false
|
||||
}
|
||||
})
|
||||
.then((response: any) => response.data)
|
||||
.catch(() => [])
|
||||
});
|
||||
|
||||
const markRead = useCallback(
|
||||
(id: number) => {
|
||||
api
|
||||
.patch(apiUrl(ApiEndpoints.news, id), {
|
||||
read: true
|
||||
})
|
||||
.then(() => {
|
||||
newsItems.refetch();
|
||||
});
|
||||
},
|
||||
[newsItems]
|
||||
);
|
||||
|
||||
const hasNews = useMemo(
|
||||
() => (newsItems.data?.length ?? 0) > 0,
|
||||
[newsItems.data]
|
||||
);
|
||||
|
||||
if (!user.isSuperuser()) {
|
||||
return (
|
||||
<Alert color="red" title={t`Requires Superuser`}>
|
||||
<Text>{t`This widget requires superuser permissions`}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<StylishText size="xl">{t`News Updates`}</StylishText>
|
||||
<ScrollArea h={400}>
|
||||
<Container>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
{hasNews ? (
|
||||
newsItems.data?.map((item: any) => (
|
||||
<NewsItem key={item.pk} item={item} onMarkRead={markRead} />
|
||||
))
|
||||
) : (
|
||||
<Alert color="green" title={t`No News`}>
|
||||
<Text>{t`There are no unread news items`}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -0,0 +1,131 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Skeleton,
|
||||
Space,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { on } from 'events';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import { ModelType } from '../../../enums/ModelType';
|
||||
import { identifierString } from '../../../functions/conversion';
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../../functions/icons';
|
||||
import { navigateToLink } from '../../../functions/navigation';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { StylishText } from '../../items/StylishText';
|
||||
import { ModelInformationDict } from '../../render/ModelType';
|
||||
import { DashboardWidgetProps } from '../DashboardWidget';
|
||||
|
||||
/**
|
||||
* A simple dashboard widget for displaying the number of results for a particular query
|
||||
*/
|
||||
function QueryCountWidget({
|
||||
modelType,
|
||||
title,
|
||||
icon,
|
||||
params
|
||||
}: {
|
||||
modelType: ModelType;
|
||||
title: string;
|
||||
icon?: InvenTreeIconType;
|
||||
params: any;
|
||||
}): ReactNode {
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const modelProperties = ModelInformationDict[modelType];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['dashboard-query-count', modelType, params],
|
||||
enabled: user.hasViewPermission(modelType),
|
||||
refetchOnMount: true,
|
||||
queryFn: () => {
|
||||
return api
|
||||
.get(apiUrl(modelProperties.api_endpoint), {
|
||||
params: {
|
||||
...params,
|
||||
limit: 1
|
||||
}
|
||||
})
|
||||
.then((res) => res.data);
|
||||
}
|
||||
});
|
||||
|
||||
const onFollowLink = useCallback(
|
||||
(event: any) => {
|
||||
if (modelProperties.url_overview) {
|
||||
let url = modelProperties.url_overview;
|
||||
|
||||
if (params) {
|
||||
url += '?';
|
||||
for (const key in params) {
|
||||
url += `${key}=${params[key]}&`;
|
||||
}
|
||||
}
|
||||
|
||||
navigateToLink(url, navigate, event);
|
||||
}
|
||||
},
|
||||
[modelProperties, params]
|
||||
);
|
||||
|
||||
// TODO: Improve visual styling
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<InvenTreeIcon icon={icon ?? modelProperties.icon} />
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<StylishText size="md">{title}</StylishText>
|
||||
<Group gap="xs" wrap="nowrap" justify="right">
|
||||
{query.isFetching ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<StylishText size="sm">{query.data?.count ?? '-'}</StylishText>
|
||||
)}
|
||||
{modelProperties?.url_overview && (
|
||||
<ActionIcon size="sm" variant="transparent" onClick={onFollowLink}>
|
||||
<IconExternalLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a dashboard widget descriptor, which displays the number of results for a particular query
|
||||
*/
|
||||
export default function QueryCountDashboardWidget({
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
modelType,
|
||||
params
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
modelType: ModelType;
|
||||
params: any;
|
||||
}): DashboardWidgetProps {
|
||||
return {
|
||||
label: label,
|
||||
title: title,
|
||||
description: description,
|
||||
minWidth: 2,
|
||||
minHeight: 1,
|
||||
render: () => (
|
||||
<QueryCountWidget modelType={modelType} title={title} params={params} />
|
||||
)
|
||||
};
|
||||
}
|
@ -34,6 +34,7 @@ import { ModelType } from '../../../enums/ModelType';
|
||||
import { TablerIconType } from '../../../functions/icons';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { TemplateI } from '../../../tables/settings/TemplateTable';
|
||||
import { Boundary } from '../../Boundary';
|
||||
import { SplitButton } from '../../buttons/SplitButton';
|
||||
import { StandaloneField } from '../../forms/StandaloneField';
|
||||
import { ModelInformationDict } from '../../render/ModelType';
|
||||
@ -41,21 +42,25 @@ import { ModelInformationDict } from '../../render/ModelType';
|
||||
type EditorProps = {
|
||||
template: TemplateI;
|
||||
};
|
||||
|
||||
type EditorRef = {
|
||||
setCode: (code: string) => void | Promise<void>;
|
||||
getCode: () => (string | undefined) | Promise<string | undefined>;
|
||||
};
|
||||
|
||||
export type EditorComponent = React.ForwardRefExoticComponent<
|
||||
EditorProps & React.RefAttributes<EditorRef>
|
||||
>;
|
||||
|
||||
export type Editor = {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: TablerIconType;
|
||||
icon?: TablerIconType;
|
||||
component: EditorComponent;
|
||||
};
|
||||
|
||||
type PreviewAreaProps = {};
|
||||
|
||||
export type PreviewAreaRef = {
|
||||
updatePreview: (
|
||||
code: string,
|
||||
@ -64,9 +69,11 @@ export type PreviewAreaRef = {
|
||||
templateEditorProps: TemplateEditorProps
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type PreviewAreaComponent = React.ForwardRefExoticComponent<
|
||||
PreviewAreaProps & React.RefAttributes<PreviewAreaRef>
|
||||
>;
|
||||
|
||||
export type PreviewArea = {
|
||||
key: string;
|
||||
name: string;
|
||||
@ -247,165 +254,171 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
}, [previewApiUrl, templateFilters]);
|
||||
|
||||
return (
|
||||
<Stack style={{ height: '100%', flex: '1' }}>
|
||||
<Split style={{ gap: '10px' }}>
|
||||
<Tabs
|
||||
value={editorValue}
|
||||
onChange={async (v) => {
|
||||
codeRef.current = await getCodeFromEditor();
|
||||
setEditorValue(v);
|
||||
}}
|
||||
keepMounted={false}
|
||||
style={{
|
||||
minWidth: '300px',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{editors.map((Editor) => (
|
||||
<Tabs.Tab
|
||||
key={Editor.key}
|
||||
value={Editor.key}
|
||||
leftSection={<Editor.icon size="0.8rem" />}
|
||||
>
|
||||
{Editor.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
|
||||
<Group justify="right" style={{ flex: '1' }} wrap="nowrap">
|
||||
<SplitButton
|
||||
loading={isPreviewLoading}
|
||||
defaultSelected="preview_save"
|
||||
name="preview-options"
|
||||
options={[
|
||||
{
|
||||
key: 'preview',
|
||||
name: t`Reload preview`,
|
||||
tooltip: t`Use the currently stored template from the server`,
|
||||
icon: IconRefresh,
|
||||
onClick: () => updatePreview(true, false),
|
||||
disabled: !previewItem || isPreviewLoading
|
||||
},
|
||||
{
|
||||
key: 'preview_save',
|
||||
name: t`Save & Reload Preview`,
|
||||
tooltip: t`Save the current template and reload the preview`,
|
||||
icon: IconDeviceFloppy,
|
||||
onClick: () => updatePreview(hasSaveConfirmed),
|
||||
disabled: !previewItem || isPreviewLoading
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Tabs.List>
|
||||
|
||||
{editors.map((Editor) => (
|
||||
<Tabs.Panel
|
||||
key={Editor.key}
|
||||
value={Editor.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: editorValue === Editor.key ? 1 : 0
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore-next-line */}
|
||||
<Editor.component ref={editorRef} template={props.template} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<Tabs
|
||||
value={previewValue}
|
||||
onChange={setPreviewValue}
|
||||
keepMounted={false}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{previewAreas.map((PreviewArea) => (
|
||||
<Tabs.Tab
|
||||
key={PreviewArea.key}
|
||||
value={PreviewArea.key}
|
||||
leftSection={<PreviewArea.icon size="0.8rem" />}
|
||||
>
|
||||
{PreviewArea.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
<div
|
||||
<Boundary label="TemplateEditor">
|
||||
<Stack style={{ height: '100%', flex: '1' }}>
|
||||
<Split style={{ gap: '10px' }}>
|
||||
<Tabs
|
||||
value={editorValue}
|
||||
onChange={async (v) => {
|
||||
codeRef.current = await getCodeFromEditor();
|
||||
setEditorValue(v);
|
||||
}}
|
||||
keepMounted={false}
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
paddingBottom: '10px',
|
||||
paddingTop: '10px'
|
||||
minWidth: '300px',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(previewApiUrl),
|
||||
description: '',
|
||||
label: t`Select instance to preview`,
|
||||
model: template.model_type,
|
||||
value: previewItem,
|
||||
filters: templateFilters,
|
||||
onValueChange: (value) => setPreviewItem(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tabs.List>
|
||||
{editors.map((Editor, index) => {
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={Editor.key}
|
||||
value={Editor.key}
|
||||
leftSection={Editor.icon && <Editor.icon size="0.8rem" />}
|
||||
>
|
||||
{Editor.name}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
|
||||
{previewAreas.map((PreviewArea) => (
|
||||
<Tabs.Panel
|
||||
key={PreviewArea.key}
|
||||
value={PreviewArea.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: previewValue === PreviewArea.key ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<Group justify="right" style={{ flex: '1' }} wrap="nowrap">
|
||||
<SplitButton
|
||||
loading={isPreviewLoading}
|
||||
defaultSelected="preview_save"
|
||||
name="preview-options"
|
||||
options={[
|
||||
{
|
||||
key: 'preview',
|
||||
name: t`Reload preview`,
|
||||
tooltip: t`Use the currently stored template from the server`,
|
||||
icon: IconRefresh,
|
||||
onClick: () => updatePreview(true, false),
|
||||
disabled: !previewItem || isPreviewLoading
|
||||
},
|
||||
{
|
||||
key: 'preview_save',
|
||||
name: t`Save & Reload Preview`,
|
||||
tooltip: t`Save the current template and reload the preview`,
|
||||
icon: IconDeviceFloppy,
|
||||
onClick: () => updatePreview(hasSaveConfirmed),
|
||||
disabled: !previewItem || isPreviewLoading
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Tabs.List>
|
||||
|
||||
{editors.map((Editor) => (
|
||||
<Tabs.Panel
|
||||
key={Editor.key}
|
||||
value={Editor.key}
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: '1'
|
||||
flex: editorValue === Editor.key ? 1 : 0
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore-next-line */}
|
||||
<PreviewArea.component ref={previewRef} />
|
||||
<Editor.component ref={editorRef} template={props.template} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{errorOverlay && (
|
||||
<Overlay color="red" center blur={0.2}>
|
||||
<CloseButton
|
||||
onClick={() => setErrorOverlay(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
color: '#fff'
|
||||
}}
|
||||
variant="filled"
|
||||
/>
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconExclamationCircle />}
|
||||
title={t`Error rendering template`}
|
||||
mx="10px"
|
||||
>
|
||||
<Code>{errorOverlay}</Code>
|
||||
</Alert>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Split>
|
||||
</Stack>
|
||||
<Tabs
|
||||
value={previewValue}
|
||||
onChange={setPreviewValue}
|
||||
keepMounted={false}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{previewAreas.map((PreviewArea) => (
|
||||
<Tabs.Tab
|
||||
key={PreviewArea.key}
|
||||
value={PreviewArea.key}
|
||||
leftSection={
|
||||
PreviewArea.icon && <PreviewArea.icon size="0.8rem" />
|
||||
}
|
||||
>
|
||||
{PreviewArea.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
<div
|
||||
style={{
|
||||
minWidth: '100%',
|
||||
paddingBottom: '10px',
|
||||
paddingTop: '10px'
|
||||
}}
|
||||
>
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(previewApiUrl),
|
||||
description: '',
|
||||
label: t`Select instance to preview`,
|
||||
model: template.model_type,
|
||||
value: previewItem,
|
||||
filters: templateFilters,
|
||||
onValueChange: (value) => setPreviewItem(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{previewAreas.map((PreviewArea) => (
|
||||
<Tabs.Panel
|
||||
key={PreviewArea.key}
|
||||
value={PreviewArea.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: previewValue === PreviewArea.key ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: '1'
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore-next-line */}
|
||||
<PreviewArea.component ref={previewRef} />
|
||||
|
||||
{errorOverlay && (
|
||||
<Overlay color="red" center blur={0.2}>
|
||||
<CloseButton
|
||||
onClick={() => setErrorOverlay(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
color: '#fff'
|
||||
}}
|
||||
variant="filled"
|
||||
/>
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconExclamationCircle />}
|
||||
title={t`Error rendering template`}
|
||||
mx="10px"
|
||||
>
|
||||
<Code>{errorOverlay}</Code>
|
||||
</Alert>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Split>
|
||||
</Stack>
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
|
@ -1,85 +0,0 @@
|
||||
import { Anchor, Group, SimpleGrid, Text } from '@mantine/core';
|
||||
|
||||
import { DocTooltip } from './DocTooltip';
|
||||
import { PlaceholderPill } from './Placeholder';
|
||||
|
||||
interface DocumentationLinkBase {
|
||||
id: string;
|
||||
title: string | JSX.Element;
|
||||
description: string | JSX.Element;
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
interface DocumentationLinkItemLink extends DocumentationLinkBase {
|
||||
link: string;
|
||||
action?: never;
|
||||
}
|
||||
|
||||
interface DocumentationLinkItemAction extends DocumentationLinkBase {
|
||||
link?: never;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export type DocumentationLinkItem =
|
||||
| DocumentationLinkItemLink
|
||||
| DocumentationLinkItemAction;
|
||||
|
||||
export function DocumentationLinks({
|
||||
links
|
||||
}: Readonly<{
|
||||
links: DocumentationLinkItem[];
|
||||
}>) {
|
||||
const DocumentationLinkRenderer = ({
|
||||
link
|
||||
}: {
|
||||
link: DocumentationLinkItem;
|
||||
}) => {
|
||||
const content = (
|
||||
<Text size="sm" fw={500}>
|
||||
{link.title}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const Linker = ({ children }: { children: any }) => {
|
||||
if (link.link)
|
||||
return (
|
||||
<Anchor href={link.link} key={link.id}>
|
||||
{children}
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
if (link.action)
|
||||
return (
|
||||
<Anchor component="button" type="button" onClick={link.action}>
|
||||
{children}
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
console.log('Neither link nor action found for link:', link);
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<Linker>
|
||||
{link.placeholder ? (
|
||||
<Group>
|
||||
{content}
|
||||
<PlaceholderPill />
|
||||
</Group>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Linker>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing={0}>
|
||||
{links.map((link) => (
|
||||
<DocTooltip key={link.id} text={link.description}>
|
||||
<DocumentationLinkRenderer link={link} />
|
||||
</DocTooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
@ -1,23 +1,16 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Carousel } from '@mantine/carousel';
|
||||
import { Anchor, Button, Paper, Text, Title } from '@mantine/core';
|
||||
import { Anchor, Button, Paper, Text } from '@mantine/core';
|
||||
|
||||
import { DocumentationLinkItem } from './DocumentationLinks';
|
||||
import * as classes from './GettingStartedCarousel.css';
|
||||
import { PlaceholderPill } from './Placeholder';
|
||||
import { MenuLinkItem } from './MenuLinks';
|
||||
import { StylishText } from './StylishText';
|
||||
|
||||
function StartedCard({
|
||||
title,
|
||||
description,
|
||||
link,
|
||||
placeholder
|
||||
}: DocumentationLinkItem) {
|
||||
function StartedCard({ title, description, link }: MenuLinkItem) {
|
||||
return (
|
||||
<Paper shadow="md" p="xl" radius="md" className={classes.card}>
|
||||
<div>
|
||||
<Title order={3} className={classes.title}>
|
||||
{title} {placeholder && <PlaceholderPill />}
|
||||
</Title>
|
||||
<StylishText size="md">{title}</StylishText>
|
||||
<Text size="sm" className={classes.category} lineClamp={2}>
|
||||
{description}
|
||||
</Text>
|
||||
@ -34,7 +27,7 @@ function StartedCard({
|
||||
export function GettingStartedCarousel({
|
||||
items
|
||||
}: Readonly<{
|
||||
items: DocumentationLinkItem[];
|
||||
items: MenuLinkItem[];
|
||||
}>) {
|
||||
const slides = items.map((item) => (
|
||||
<Carousel.Slide key={item.id}>
|
||||
|
@ -1,75 +1,109 @@
|
||||
import { SimpleGrid, Text, UnstyledButton } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton
|
||||
} from '@mantine/core';
|
||||
import { IconLink } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import * as classes from '../../main.css';
|
||||
import { DocTooltip } from './DocTooltip';
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { StylishText } from './StylishText';
|
||||
|
||||
export interface MenuLinkItem {
|
||||
id: string;
|
||||
text: string | JSX.Element;
|
||||
link: string;
|
||||
highlight?: boolean;
|
||||
doctext?: string | JSX.Element;
|
||||
docdetail?: string | JSX.Element;
|
||||
doclink?: string;
|
||||
docchildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
export type menuItemsCollection = {
|
||||
[key: string]: MenuLinkItem;
|
||||
};
|
||||
|
||||
function ConditionalDocTooltip({
|
||||
item,
|
||||
children
|
||||
}: Readonly<{
|
||||
item: MenuLinkItem;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
if (item.doctext !== undefined) {
|
||||
return (
|
||||
<DocTooltip
|
||||
key={item.id}
|
||||
text={item.doctext}
|
||||
detail={item?.docdetail}
|
||||
link={item?.doclink}
|
||||
docchildren={item?.docchildren}
|
||||
>
|
||||
{children}
|
||||
</DocTooltip>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
title: string | JSX.Element;
|
||||
description?: string;
|
||||
icon?: InvenTreeIconType;
|
||||
action?: () => void;
|
||||
link?: string;
|
||||
external?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export function MenuLinks({
|
||||
title,
|
||||
links,
|
||||
highlighted = false
|
||||
beforeClick
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
links: MenuLinkItem[];
|
||||
highlighted?: boolean;
|
||||
beforeClick?: () => void;
|
||||
}>) {
|
||||
const filteredLinks = links.filter(
|
||||
(item) => !highlighted || item.highlight === true
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Filter out any hidden links
|
||||
const visibleLinks = useMemo(
|
||||
() => links.filter((item) => !item.hidden),
|
||||
[links]
|
||||
);
|
||||
|
||||
if (visibleLinks.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing={0}>
|
||||
{filteredLinks.map((item) => (
|
||||
<ConditionalDocTooltip item={item} key={item.id}>
|
||||
<UnstyledButton
|
||||
className={classes.subLink}
|
||||
component={Link}
|
||||
to={item.link}
|
||||
p={0}
|
||||
>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</ConditionalDocTooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Divider />
|
||||
<StylishText size="md">{title}</StylishText>
|
||||
<Divider />
|
||||
<SimpleGrid cols={2} spacing={0} p={3}>
|
||||
{visibleLinks.map((item) => (
|
||||
<Tooltip
|
||||
key={`menu-link-tooltip-${item.id}`}
|
||||
label={item.description}
|
||||
hidden={!item.description}
|
||||
>
|
||||
{item.link && item.external ? (
|
||||
<Anchor href={item.link}>
|
||||
<Group wrap="nowrap">
|
||||
{item.external && (
|
||||
<InvenTreeIcon
|
||||
icon={item.icon ?? 'link'}
|
||||
iconProps={{ size: '14' }}
|
||||
/>
|
||||
)}
|
||||
<Text fw={500} p={5}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : (
|
||||
<UnstyledButton
|
||||
onClick={(event) => {
|
||||
if (item.link) {
|
||||
beforeClick?.();
|
||||
navigateToLink(item.link, navigate, event);
|
||||
} else if (item.action) {
|
||||
beforeClick?.();
|
||||
item.action();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{item.icon && (
|
||||
<InvenTreeIcon
|
||||
icon={item.icon}
|
||||
iconProps={{ size: '14' }}
|
||||
/>
|
||||
)}
|
||||
<Text fw={500} p={5}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,28 +1,11 @@
|
||||
import { Anchor, Container, Group } from '@mantine/core';
|
||||
|
||||
import { footerLinks } from '../../defaults/links';
|
||||
import * as classes from '../../main.css';
|
||||
import { InvenTreeLogoHomeButton } from '../items/InvenTreeLogo';
|
||||
|
||||
export function Footer() {
|
||||
const items = footerLinks.map((link) => (
|
||||
<Anchor<'a'>
|
||||
c="dimmed"
|
||||
key={link.key}
|
||||
href={link.link}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
size="sm"
|
||||
>
|
||||
{link.label}
|
||||
</Anchor>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={classes.layoutFooter}>
|
||||
<Container className={classes.layoutFooterInner} size={'100%'}>
|
||||
<InvenTreeLogoHomeButton />
|
||||
<Group className={classes.layoutFooterLinks}>{items}</Group>
|
||||
</Container>
|
||||
{
|
||||
// Placeholder for footer links
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import { navigateToLink } from '../../functions/navigation';
|
||||
import * as classes from '../../main.css';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { ScanButton } from '../buttons/ScanButton';
|
||||
import { SpotlightButton } from '../buttons/SpotlightButton';
|
||||
@ -42,6 +43,8 @@ export function Header() {
|
||||
|
||||
const [notificationCount, setNotificationCount] = useState<number>(0);
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
// Fetch number of notifications for the current user
|
||||
const notifications = useQuery({
|
||||
queryKey: ['notification-count'],
|
||||
@ -111,7 +114,7 @@ export function Header() {
|
||||
<IconSearch />
|
||||
</ActionIcon>
|
||||
<SpotlightButton />
|
||||
<ScanButton />
|
||||
{globalSettings.isSet('BARCODE_ENABLE') && <ScanButton />}
|
||||
<Indicator
|
||||
radius="lg"
|
||||
size="18"
|
||||
|
@ -1,117 +1,15 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
HoverCard,
|
||||
Skeleton,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme
|
||||
} from '@mantine/core';
|
||||
import { IconLayoutSidebar } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UnstyledButton } from '@mantine/core';
|
||||
|
||||
import { menuItems } from '../../defaults/menuItems';
|
||||
import * as classes from '../../main.css';
|
||||
import { useServerApiState } from '../../states/ApiState';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { vars } from '../../theme';
|
||||
import { InvenTreeLogo } from '../items/InvenTreeLogo';
|
||||
import { MenuLinks } from '../items/MenuLinks';
|
||||
|
||||
const onlyItems = Object.values(menuItems);
|
||||
|
||||
export function NavHoverMenu({
|
||||
openDrawer: openDrawer
|
||||
}: Readonly<{
|
||||
openDrawer: () => void;
|
||||
}>) {
|
||||
const [hostKey, hostList] = useLocalState((state) => [
|
||||
state.hostKey,
|
||||
state.hostList
|
||||
]);
|
||||
const [servername] = useServerApiState((state) => [state.server.instance]);
|
||||
const [instanceName, setInstanceName] = useState<string>();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (hostKey && hostList[hostKey]) {
|
||||
setInstanceName(hostList[hostKey]?.name);
|
||||
}
|
||||
}, [hostKey]);
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
width={600}
|
||||
openDelay={300}
|
||||
position="bottom"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<UnstyledButton onClick={() => openDrawer()} aria-label="Homenav">
|
||||
<InvenTreeLogo />
|
||||
</UnstyledButton>
|
||||
</HoverCard.Target>
|
||||
|
||||
<HoverCard.Dropdown style={{ overflow: 'hidden' }}>
|
||||
<Group justify="space-between" px="md">
|
||||
<ActionIcon
|
||||
onClick={openDrawer}
|
||||
onMouseOver={openDrawer}
|
||||
title={t`Open Navigation`}
|
||||
variant="default"
|
||||
>
|
||||
<IconLayoutSidebar />
|
||||
</ActionIcon>
|
||||
<Group gap={'xs'}>
|
||||
{instanceName ? (
|
||||
instanceName
|
||||
) : (
|
||||
<Skeleton height={20} width={40} radius={vars.radiusDefault} />
|
||||
)}{' '}
|
||||
|{' '}
|
||||
{servername ? (
|
||||
servername
|
||||
) : (
|
||||
<Skeleton height={20} width={40} radius={vars.radiusDefault} />
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href="#" fz="xs" onClick={openDrawer}>
|
||||
<Trans>View all</Trans>
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Divider
|
||||
my="sm"
|
||||
mx="-md"
|
||||
color={
|
||||
colorScheme === 'dark' ? vars.colors.dark[5] : vars.colors.gray[1]
|
||||
}
|
||||
/>
|
||||
<MenuLinks links={onlyItems} highlighted={true} />
|
||||
<div className={classes.headerDropdownFooter}>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={500} fz="sm">
|
||||
<Trans>Get started</Trans>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>
|
||||
Overview over high-level objects, functions and possible
|
||||
usecases.
|
||||
</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="default">
|
||||
<Trans>Get started</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
<UnstyledButton onClick={() => openDrawer()} aria-label="navigation-menu">
|
||||
<InvenTreeLogo />
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
@ -3,22 +3,26 @@ import {
|
||||
Container,
|
||||
Drawer,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Space,
|
||||
Title
|
||||
Space
|
||||
} from '@mantine/core';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { aboutLinks, navDocLinks } from '../../defaults/links';
|
||||
import { menuItems } from '../../defaults/menuItems';
|
||||
import { AboutLinks, DocumentationLinks } from '../../defaults/links';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import useInstanceName from '../../hooks/UseInstanceName';
|
||||
import * as classes from '../../main.css';
|
||||
import { DocumentationLinks } from '../items/DocumentationLinks';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { InvenTreeLogo } from '../items/InvenTreeLogo';
|
||||
import { MenuLinkItem, MenuLinks } from '../items/MenuLinks';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
// TODO @matmair #1: implement plugin loading and menu item generation see #5269
|
||||
const plugins: MenuLinkItem[] = [];
|
||||
const onlyItems = Object.values(menuItems);
|
||||
|
||||
export function NavigationDrawer({
|
||||
opened,
|
||||
@ -31,39 +35,163 @@ export function NavigationDrawer({
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
overlayProps={{ opacity: 0.5, blur: 4 }}
|
||||
size="lg"
|
||||
withCloseButton={false}
|
||||
classNames={{
|
||||
body: classes.navigationDrawer
|
||||
}}
|
||||
>
|
||||
<DrawerContent />
|
||||
<DrawerContent closeFunc={close} />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
function DrawerContent() {
|
||||
|
||||
function DrawerContent({ closeFunc }: { closeFunc?: () => void }) {
|
||||
const user = useUserState();
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const [scrollHeight, setScrollHeight] = useState(0);
|
||||
const ref = useRef(null);
|
||||
const { height } = useViewportSize();
|
||||
|
||||
const title = useInstanceName();
|
||||
|
||||
// update scroll height when viewport size changes
|
||||
useEffect(() => {
|
||||
if (ref.current == null) return;
|
||||
setScrollHeight(height - ref.current['clientHeight'] - 65);
|
||||
});
|
||||
|
||||
// Construct menu items
|
||||
const menuItemsNavigate: MenuLinkItem[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
title: t`Dashboard`,
|
||||
link: '/',
|
||||
icon: 'dashboard'
|
||||
},
|
||||
{
|
||||
id: 'parts',
|
||||
title: t`Parts`,
|
||||
hidden: !user.hasViewPermission(ModelType.part),
|
||||
link: '/part',
|
||||
icon: 'part'
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
title: t`Stock`,
|
||||
link: '/stock',
|
||||
hidden: !user.hasViewPermission(ModelType.stockitem),
|
||||
icon: 'stock'
|
||||
},
|
||||
{
|
||||
id: 'build',
|
||||
title: t`Manufacturing`,
|
||||
link: '/manufacturing/',
|
||||
hidden: !user.hasViewRole(UserRoles.build),
|
||||
icon: 'build'
|
||||
},
|
||||
{
|
||||
id: 'purchasing',
|
||||
title: t`Purchasing`,
|
||||
link: '/purchasing/',
|
||||
hidden: !user.hasViewRole(UserRoles.purchase_order),
|
||||
icon: 'purchase_orders'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
title: t`Sales`,
|
||||
link: '/sales/',
|
||||
hidden: !user.hasViewRole(UserRoles.sales_order),
|
||||
icon: 'sales_orders'
|
||||
}
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
const menuItemsAction: MenuLinkItem[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'barcode',
|
||||
title: t`Scan Barcode`,
|
||||
link: '/scan',
|
||||
icon: 'barcode',
|
||||
hidden: !globalSettings.isSet('BARCODE_ENABLE')
|
||||
}
|
||||
];
|
||||
}, [user, globalSettings]);
|
||||
|
||||
const menuItemsSettings: MenuLinkItem[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'notifications',
|
||||
title: t`Notifications`,
|
||||
link: '/notifications',
|
||||
icon: 'notification'
|
||||
},
|
||||
{
|
||||
id: 'user-settings',
|
||||
title: t`User Settings`,
|
||||
link: '/settings/user',
|
||||
icon: 'user'
|
||||
},
|
||||
{
|
||||
id: 'system-settings',
|
||||
title: t`System Settings`,
|
||||
link: '/settings/system',
|
||||
icon: 'system',
|
||||
hidden: !user.isStaff()
|
||||
},
|
||||
{
|
||||
id: 'admin-center',
|
||||
title: t`Admin Center`,
|
||||
link: '/settings/admin',
|
||||
icon: 'admin',
|
||||
hidden: !user.isStaff()
|
||||
}
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
const menuItemsDocumentation: MenuLinkItem[] = useMemo(
|
||||
() => DocumentationLinks(),
|
||||
[]
|
||||
);
|
||||
|
||||
const menuItemsAbout: MenuLinkItem[] = useMemo(() => AboutLinks(), []);
|
||||
|
||||
return (
|
||||
<Flex direction="column" mih="100vh" p={16}>
|
||||
<Title order={3}>{t`Navigation`}</Title>
|
||||
<Group wrap="nowrap">
|
||||
<InvenTreeLogo />
|
||||
<StylishText size="xl">{title}</StylishText>
|
||||
</Group>
|
||||
<Space h="xs" />
|
||||
<Container className={classes.layoutContent} p={0}>
|
||||
<ScrollArea h={scrollHeight} type="always" offsetScrollbars>
|
||||
<Title order={5}>{t`Pages`}</Title>
|
||||
<MenuLinks links={onlyItems} />
|
||||
<MenuLinks
|
||||
title={t`Navigation`}
|
||||
links={menuItemsNavigate}
|
||||
beforeClick={closeFunc}
|
||||
/>
|
||||
<MenuLinks
|
||||
title={t`Settings`}
|
||||
links={menuItemsSettings}
|
||||
beforeClick={closeFunc}
|
||||
/>
|
||||
<MenuLinks
|
||||
title={t`Actions`}
|
||||
links={menuItemsAction}
|
||||
beforeClick={closeFunc}
|
||||
/>
|
||||
<Space h="md" />
|
||||
{plugins.length > 0 ? (
|
||||
<>
|
||||
<Title order={5}>{t`Plugins`}</Title>
|
||||
<MenuLinks links={plugins} />
|
||||
<MenuLinks
|
||||
title={t`Plugins`}
|
||||
links={plugins}
|
||||
beforeClick={closeFunc}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
@ -72,11 +200,17 @@ function DrawerContent() {
|
||||
</Container>
|
||||
<div ref={ref}>
|
||||
<Space h="md" />
|
||||
<Title order={5}>{t`Documentation`}</Title>
|
||||
<DocumentationLinks links={navDocLinks} />
|
||||
<MenuLinks
|
||||
title={t`Documentation`}
|
||||
links={menuItemsDocumentation}
|
||||
beforeClick={closeFunc}
|
||||
/>
|
||||
<Space h="md" />
|
||||
<Title order={5}>{t`About`}</Title>
|
||||
<DocumentationLinks links={aboutLinks} />
|
||||
<MenuLinks
|
||||
title={t`About`}
|
||||
links={menuItemsAbout}
|
||||
beforeClick={closeFunc}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -28,6 +28,7 @@ import { UserStateProps, useUserState } from '../../states/UserState';
|
||||
* @param navigate - The navigation function (see react-router-dom)
|
||||
* @param theme - The current Mantine theme
|
||||
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
|
||||
* @param context - Any additional context data which may be passed to the plugin
|
||||
*/
|
||||
export type InvenTreeContext = {
|
||||
api: AxiosInstance;
|
||||
@ -38,6 +39,7 @@ export type InvenTreeContext = {
|
||||
navigate: NavigateFunction;
|
||||
theme: MantineTheme;
|
||||
colorScheme: MantineColorScheme;
|
||||
context?: any;
|
||||
};
|
||||
|
||||
export const useInvenTreeContext = () => {
|
||||
|
@ -147,10 +147,7 @@ export default function PluginDrawer({
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Card withBorder>
|
||||
<PluginSettingsPanel
|
||||
pluginInstance={pluginInstance}
|
||||
pluginAdmin={pluginAdmin}
|
||||
/>
|
||||
<PluginSettingsPanel pluginAdmin={pluginAdmin} />
|
||||
</Card>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
@ -1,53 +1,9 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { InvenTreeContext } from './PluginContext';
|
||||
import { findExternalPluginFunction } from './PluginSource';
|
||||
|
||||
// Definition of the plugin panel properties, provided by the server API
|
||||
export type PluginPanelProps = {
|
||||
plugin: string;
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
content?: string;
|
||||
context?: any;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export async function isPluginPanelHidden({
|
||||
pluginProps,
|
||||
pluginContext
|
||||
}: {
|
||||
pluginProps: PluginPanelProps;
|
||||
pluginContext: InvenTreeContext;
|
||||
}): Promise<boolean> {
|
||||
if (!pluginProps.source) {
|
||||
// No custom source supplied - panel is not hidden
|
||||
return false;
|
||||
}
|
||||
|
||||
const func = await findExternalPluginFunction(
|
||||
pluginProps.source,
|
||||
'isPanelHidden'
|
||||
);
|
||||
|
||||
if (!func) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return func(pluginContext);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error occurred while checking if plugin panel is hidden:',
|
||||
error
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
import { PluginUIFeature } from './PluginUIFeature';
|
||||
import RemoteComponent from './RemoteComponent';
|
||||
|
||||
/**
|
||||
* A custom panel which can be used to display plugin content.
|
||||
@ -63,63 +19,19 @@ export async function isPluginPanelHidden({
|
||||
* - `params` is the set of run-time parameters to pass to the content rendering function
|
||||
*/
|
||||
export default function PluginPanelContent({
|
||||
pluginProps,
|
||||
pluginFeature,
|
||||
pluginContext
|
||||
}: Readonly<{
|
||||
pluginProps: PluginPanelProps;
|
||||
pluginFeature: PluginUIFeature;
|
||||
pluginContext: InvenTreeContext;
|
||||
}>): ReactNode {
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const reloadPluginContent = async () => {
|
||||
// If a "source" URL is provided, load the content from that URL
|
||||
if (pluginProps.source) {
|
||||
findExternalPluginFunction(pluginProps.source, 'renderPanel').then(
|
||||
(func) => {
|
||||
if (func) {
|
||||
try {
|
||||
func(ref.current, pluginContext);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
setError(
|
||||
t`Error occurred while rendering plugin content` + `: ${error}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(t`Plugin did not provide panel rendering function`);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (pluginProps.content) {
|
||||
// If content is provided directly, render it into the panel
|
||||
if (ref.current) {
|
||||
ref.current?.setHTMLUnsafe(pluginProps.content.toString());
|
||||
setError('');
|
||||
}
|
||||
} else {
|
||||
// If no content is provided, display a placeholder
|
||||
setError(t`No content provided for this plugin`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadPluginContent();
|
||||
}, [pluginProps, pluginContext]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error Loading Plugin`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div ref={ref as any}></div>
|
||||
<RemoteComponent
|
||||
source={pluginFeature.source}
|
||||
defaultFunctionName="renderPanel"
|
||||
context={pluginContext}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useInvenTreeContext } from './PluginContext';
|
||||
import { findExternalPluginFunction } from './PluginSource';
|
||||
import RemoteComponent from './RemoteComponent';
|
||||
|
||||
/**
|
||||
* Interface for the plugin admin data
|
||||
@ -22,65 +17,17 @@ export interface PluginAdminInterface {
|
||||
* which exports a function `renderPluginSettings`
|
||||
*/
|
||||
export default function PluginSettingsPanel({
|
||||
pluginInstance,
|
||||
pluginAdmin
|
||||
}: {
|
||||
pluginInstance: any;
|
||||
pluginAdmin: PluginAdminInterface;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const pluginContext = useInvenTreeContext();
|
||||
|
||||
const pluginSourceFile = useMemo(() => pluginAdmin?.source, [pluginInstance]);
|
||||
|
||||
const loadPluginSettingsContent = async () => {
|
||||
if (pluginSourceFile) {
|
||||
findExternalPluginFunction(pluginSourceFile, 'renderPluginSettings').then(
|
||||
(func) => {
|
||||
if (func) {
|
||||
try {
|
||||
func(ref.current, {
|
||||
...pluginContext,
|
||||
context: pluginAdmin.context
|
||||
});
|
||||
setError('');
|
||||
} catch (error) {
|
||||
setError(
|
||||
t`Error occurred while rendering plugin settings` + `: ${error}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(t`Plugin did not provide settings rendering function`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPluginSettingsContent();
|
||||
}, [pluginSourceFile]);
|
||||
|
||||
if (!pluginSourceFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error Loading Plugin`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div ref={ref as any}></div>
|
||||
</Stack>
|
||||
</>
|
||||
<RemoteComponent
|
||||
source={pluginAdmin.source}
|
||||
defaultFunctionName="renderPluginSettings"
|
||||
context={{ ...pluginContext, context: pluginAdmin.context }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -21,6 +21,42 @@ import {
|
||||
TemplatePreviewUIFeature
|
||||
} from './PluginUIFeatureTypes';
|
||||
|
||||
/**
|
||||
* Enumeration for available plugin UI feature types.
|
||||
*/
|
||||
export enum PluginUIFeatureType {
|
||||
dashboard = 'dashboard',
|
||||
panel = 'panel',
|
||||
template_editor = 'template_editor',
|
||||
template_preview = 'template_preview'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definition for a UI component which can be loaded via plugin.
|
||||
* Ref: src/backend/InvenTree/plugin/base/ui/serializers.py:PluginUIFeatureSerializer
|
||||
*
|
||||
* @param plugin_name: The name of the plugin
|
||||
* @param feature_type: The type of the UI feature (see PluginUIFeatureType)
|
||||
* @param key: The unique key for the feature (used to identify the feature in the DOM)
|
||||
* @param title: The title of the feature (human readable)
|
||||
* @param description: A description of the feature (human readable, optional)
|
||||
* @param options: Additional options for the feature (optional, depends on the feature type)
|
||||
* @param context: Additional context data passed to the rendering function (optional)
|
||||
* @param source: The source of the feature (must point to an accessible javascript module)
|
||||
*
|
||||
*/
|
||||
export interface PluginUIFeature {
|
||||
plugin_name: string;
|
||||
feature_type: PluginUIFeatureType;
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
options?: any;
|
||||
context?: any;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export const getPluginTemplateEditor = (
|
||||
func: PluginUIFuncWithoutInvenTreeContextType<TemplateEditorUIFeature>,
|
||||
template: TemplateI
|
||||
|
@ -3,6 +3,7 @@ import { InvenTreeIconType } from '../../functions/icons';
|
||||
import { TemplateI } from '../../tables/settings/TemplateTable';
|
||||
import { TemplateEditorProps } from '../editors/TemplateEditor/TemplateEditor';
|
||||
import { InvenTreeContext } from './PluginContext';
|
||||
import { PluginUIFeature } from './PluginUIFeature';
|
||||
|
||||
// #region Type Helpers
|
||||
export type BaseUIFeature = {
|
||||
@ -35,11 +36,7 @@ export type TemplateEditorUIFeature = {
|
||||
template_type: ModelType.labeltemplate | ModelType.reporttemplate;
|
||||
template_model: ModelType;
|
||||
};
|
||||
responseOptions: {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: InvenTreeIconType;
|
||||
};
|
||||
responseOptions: PluginUIFeature;
|
||||
featureContext: {
|
||||
ref: HTMLDivElement;
|
||||
registerHandlers: (handlers: {
|
||||
|
105
src/frontend/src/components/plugins/RemoteComponent.tsx
Normal file
105
src/frontend/src/components/plugins/RemoteComponent.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { InvenTreeContext } from './PluginContext';
|
||||
import { findExternalPluginFunction } from './PluginSource';
|
||||
|
||||
/**
|
||||
* A remote component which can be used to display plugin content.
|
||||
* Content is loaded dynamically (from an external source).
|
||||
*
|
||||
* @param pluginFeature: The plugin feature to render
|
||||
* @param defaultFunctionName: The default function name to call (if not overridden by pluginFeature.source)
|
||||
* @param pluginContext: The context to pass to the plugin function
|
||||
*
|
||||
*/
|
||||
export default function RemoteComponent({
|
||||
source,
|
||||
defaultFunctionName,
|
||||
context
|
||||
}: {
|
||||
source: string;
|
||||
defaultFunctionName: string;
|
||||
context: InvenTreeContext;
|
||||
}) {
|
||||
const componentRef = useRef<HTMLDivElement>();
|
||||
|
||||
const [renderingError, setRenderingError] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const sourceFile = useMemo(() => {
|
||||
return source.split(':')[0];
|
||||
}, [source]);
|
||||
|
||||
// Determine the function to call in the external plugin source
|
||||
const functionName = useMemo(() => {
|
||||
// The "source" string may contain a function name, e.g. "source.js:myFunction"
|
||||
if (source.includes(':')) {
|
||||
return source.split(':')[1];
|
||||
}
|
||||
|
||||
// By default, return the default function name
|
||||
return defaultFunctionName;
|
||||
}, [source, defaultFunctionName]);
|
||||
|
||||
const reloadPluginContent = async () => {
|
||||
if (!componentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceFile && functionName) {
|
||||
findExternalPluginFunction(sourceFile, functionName).then((func) => {
|
||||
if (func) {
|
||||
try {
|
||||
func(componentRef.current, context);
|
||||
setRenderingError('');
|
||||
} catch (error) {
|
||||
setRenderingError(`${error}`);
|
||||
}
|
||||
} else {
|
||||
setRenderingError(`${sourceFile}:${functionName}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setRenderingError(
|
||||
t`Invalid source or function name` + ` - ${sourceFile}:${functionName}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Reload the plugin content dynamically
|
||||
useEffect(() => {
|
||||
reloadPluginContent();
|
||||
}, [sourceFile, functionName, context]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Boundary
|
||||
label={identifierString(
|
||||
`RemoteComponent-${sourceFile}-${functionName}`
|
||||
)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{renderingError && (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error Loading Content`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>
|
||||
{t`Error occurred while loading plugin content`}:{' '}
|
||||
{renderingError}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div ref={componentRef as any}></div>
|
||||
</Stack>
|
||||
</Boundary>
|
||||
</>
|
||||
);
|
||||
}
|
@ -106,7 +106,6 @@ export type RenderInstanceProps = {
|
||||
*/
|
||||
export function RenderInstance(props: RenderInstanceProps): ReactNode {
|
||||
if (props.model === undefined) {
|
||||
console.error('RenderInstance: No model provided');
|
||||
return <UnknownRenderer model={props.model} />;
|
||||
}
|
||||
|
||||
@ -115,7 +114,6 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
|
||||
const RenderComponent = RendererLookup[model_name];
|
||||
|
||||
if (!RenderComponent) {
|
||||
console.error(`RenderInstance: No renderer for model ${props.model}`);
|
||||
return <UnknownRenderer model={props.model} />;
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
|
||||
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { InvenTreeIconType } from '../../functions/icons';
|
||||
|
||||
export interface ModelInformationInterface {
|
||||
label: string;
|
||||
@ -11,6 +12,7 @@ export interface ModelInformationInterface {
|
||||
api_endpoint: ApiEndpoints;
|
||||
cui_detail?: string;
|
||||
admin_url?: string;
|
||||
icon: InvenTreeIconType;
|
||||
}
|
||||
|
||||
export interface TranslatableModelInformationInterface
|
||||
@ -27,25 +29,28 @@ export const ModelInformationDict: ModelDict = {
|
||||
part: {
|
||||
label: () => t`Part`,
|
||||
label_multiple: () => t`Parts`,
|
||||
url_overview: '/part',
|
||||
url_overview: '/part/category/index/parts',
|
||||
url_detail: '/part/:pk/',
|
||||
cui_detail: '/part/:pk/',
|
||||
api_endpoint: ApiEndpoints.part_list,
|
||||
admin_url: '/part/part/'
|
||||
admin_url: '/part/part/',
|
||||
icon: 'part'
|
||||
},
|
||||
partparametertemplate: {
|
||||
label: () => t`Part Parameter Template`,
|
||||
label_multiple: () => t`Part Parameter Templates`,
|
||||
url_overview: '/partparametertemplate',
|
||||
url_detail: '/partparametertemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.part_parameter_template_list
|
||||
api_endpoint: ApiEndpoints.part_parameter_template_list,
|
||||
icon: 'test_templates'
|
||||
},
|
||||
parttesttemplate: {
|
||||
label: () => t`Part Test Template`,
|
||||
label_multiple: () => t`Part Test Templates`,
|
||||
url_overview: '/parttesttemplate',
|
||||
url_detail: '/parttesttemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.part_test_template_list
|
||||
api_endpoint: ApiEndpoints.part_test_template_list,
|
||||
icon: 'test'
|
||||
},
|
||||
supplierpart: {
|
||||
label: () => t`Supplier Part`,
|
||||
@ -54,7 +59,8 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/purchasing/supplier-part/:pk/',
|
||||
cui_detail: '/supplier-part/:pk/',
|
||||
api_endpoint: ApiEndpoints.supplier_part_list,
|
||||
admin_url: '/company/supplierpart/'
|
||||
admin_url: '/company/supplierpart/',
|
||||
icon: 'supplier_part'
|
||||
},
|
||||
manufacturerpart: {
|
||||
label: () => t`Manufacturer Part`,
|
||||
@ -63,25 +69,28 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/purchasing/manufacturer-part/:pk/',
|
||||
cui_detail: '/manufacturer-part/:pk/',
|
||||
api_endpoint: ApiEndpoints.manufacturer_part_list,
|
||||
admin_url: '/company/manufacturerpart/'
|
||||
admin_url: '/company/manufacturerpart/',
|
||||
icon: 'manufacturers'
|
||||
},
|
||||
partcategory: {
|
||||
label: () => t`Part Category`,
|
||||
label_multiple: () => t`Part Categories`,
|
||||
url_overview: '/part/category',
|
||||
url_overview: '/part/category/parts/subcategories',
|
||||
url_detail: '/part/category/:pk/',
|
||||
cui_detail: '/part/category/:pk/',
|
||||
api_endpoint: ApiEndpoints.category_list,
|
||||
admin_url: '/part/partcategory/'
|
||||
admin_url: '/part/partcategory/',
|
||||
icon: 'category'
|
||||
},
|
||||
stockitem: {
|
||||
label: () => t`Stock Item`,
|
||||
label_multiple: () => t`Stock Items`,
|
||||
url_overview: '/stock/item',
|
||||
url_overview: '/stock/location/index/stock-items',
|
||||
url_detail: '/stock/item/:pk/',
|
||||
cui_detail: '/stock/item/:pk/',
|
||||
api_endpoint: ApiEndpoints.stock_item_list,
|
||||
admin_url: '/stock/stockitem/'
|
||||
admin_url: '/stock/stockitem/',
|
||||
icon: 'stock'
|
||||
},
|
||||
stocklocation: {
|
||||
label: () => t`Stock Location`,
|
||||
@ -90,26 +99,30 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/stock/location/:pk/',
|
||||
cui_detail: '/stock/location/:pk/',
|
||||
api_endpoint: ApiEndpoints.stock_location_list,
|
||||
admin_url: '/stock/stocklocation/'
|
||||
admin_url: '/stock/stocklocation/',
|
||||
icon: 'location'
|
||||
},
|
||||
stocklocationtype: {
|
||||
label: () => t`Stock Location Type`,
|
||||
label_multiple: () => t`Stock Location Types`,
|
||||
api_endpoint: ApiEndpoints.stock_location_type_list
|
||||
api_endpoint: ApiEndpoints.stock_location_type_list,
|
||||
icon: 'location'
|
||||
},
|
||||
stockhistory: {
|
||||
label: () => t`Stock History`,
|
||||
label_multiple: () => t`Stock Histories`,
|
||||
api_endpoint: ApiEndpoints.stock_tracking_list
|
||||
api_endpoint: ApiEndpoints.stock_tracking_list,
|
||||
icon: 'history'
|
||||
},
|
||||
build: {
|
||||
label: () => t`Build`,
|
||||
label_multiple: () => t`Builds`,
|
||||
url_overview: '/manufacturing/build-order/',
|
||||
url_overview: '/manufacturing/index/buildorders/',
|
||||
url_detail: '/manufacturing/build-order/:pk/',
|
||||
cui_detail: '/build/:pk/',
|
||||
api_endpoint: ApiEndpoints.build_order_list,
|
||||
admin_url: '/build/build/'
|
||||
admin_url: '/build/build/',
|
||||
icon: 'build_order'
|
||||
},
|
||||
buildline: {
|
||||
label: () => t`Build Line`,
|
||||
@ -117,12 +130,14 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_overview: '/build/line',
|
||||
url_detail: '/build/line/:pk/',
|
||||
cui_detail: '/build/line/:pk/',
|
||||
api_endpoint: ApiEndpoints.build_line_list
|
||||
api_endpoint: ApiEndpoints.build_line_list,
|
||||
icon: 'build_order'
|
||||
},
|
||||
builditem: {
|
||||
label: () => t`Build Item`,
|
||||
label_multiple: () => t`Build Items`,
|
||||
api_endpoint: ApiEndpoints.build_item_list
|
||||
api_endpoint: ApiEndpoints.build_item_list,
|
||||
icon: 'build_order'
|
||||
},
|
||||
company: {
|
||||
label: () => t`Company`,
|
||||
@ -131,86 +146,98 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/company/:pk/',
|
||||
cui_detail: '/company/:pk/',
|
||||
api_endpoint: ApiEndpoints.company_list,
|
||||
admin_url: '/company/company/'
|
||||
admin_url: '/company/company/',
|
||||
icon: 'building'
|
||||
},
|
||||
projectcode: {
|
||||
label: () => t`Project Code`,
|
||||
label_multiple: () => t`Project Codes`,
|
||||
url_overview: '/project-code',
|
||||
url_detail: '/project-code/:pk/',
|
||||
api_endpoint: ApiEndpoints.project_code_list
|
||||
api_endpoint: ApiEndpoints.project_code_list,
|
||||
icon: 'list_details'
|
||||
},
|
||||
purchaseorder: {
|
||||
label: () => t`Purchase Order`,
|
||||
label_multiple: () => t`Purchase Orders`,
|
||||
url_overview: '/purchasing/purchase-order',
|
||||
url_overview: '/purchasing/index/purchaseorders',
|
||||
url_detail: '/purchasing/purchase-order/:pk/',
|
||||
cui_detail: '/order/purchase-order/:pk/',
|
||||
api_endpoint: ApiEndpoints.purchase_order_list,
|
||||
admin_url: '/order/purchaseorder/'
|
||||
admin_url: '/order/purchaseorder/',
|
||||
icon: 'purchase_orders'
|
||||
},
|
||||
purchaseorderlineitem: {
|
||||
label: () => t`Purchase Order Line`,
|
||||
label_multiple: () => t`Purchase Order Lines`,
|
||||
api_endpoint: ApiEndpoints.purchase_order_line_list
|
||||
api_endpoint: ApiEndpoints.purchase_order_line_list,
|
||||
icon: 'purchase_orders'
|
||||
},
|
||||
salesorder: {
|
||||
label: () => t`Sales Order`,
|
||||
label_multiple: () => t`Sales Orders`,
|
||||
url_overview: '/sales/sales-order',
|
||||
url_overview: '/sales/index/salesorders',
|
||||
url_detail: '/sales/sales-order/:pk/',
|
||||
cui_detail: '/order/sales-order/:pk/',
|
||||
api_endpoint: ApiEndpoints.sales_order_list,
|
||||
admin_url: '/order/salesorder/'
|
||||
admin_url: '/order/salesorder/',
|
||||
icon: 'sales_orders'
|
||||
},
|
||||
salesordershipment: {
|
||||
label: () => t`Sales Order Shipment`,
|
||||
label_multiple: () => t`Sales Order Shipments`,
|
||||
url_overview: '/sales/shipment/',
|
||||
url_detail: '/sales/shipment/:pk/',
|
||||
api_endpoint: ApiEndpoints.sales_order_shipment_list
|
||||
api_endpoint: ApiEndpoints.sales_order_shipment_list,
|
||||
icon: 'sales_orders'
|
||||
},
|
||||
returnorder: {
|
||||
label: () => t`Return Order`,
|
||||
label_multiple: () => t`Return Orders`,
|
||||
url_overview: '/sales/return-order',
|
||||
url_overview: '/sales/index/returnorders',
|
||||
url_detail: '/sales/return-order/:pk/',
|
||||
cui_detail: '/order/return-order/:pk/',
|
||||
api_endpoint: ApiEndpoints.return_order_list,
|
||||
admin_url: '/order/returnorder/'
|
||||
admin_url: '/order/returnorder/',
|
||||
icon: 'return_orders'
|
||||
},
|
||||
returnorderlineitem: {
|
||||
label: () => t`Return Order Line Item`,
|
||||
label_multiple: () => t`Return Order Line Items`,
|
||||
api_endpoint: ApiEndpoints.return_order_line_list
|
||||
api_endpoint: ApiEndpoints.return_order_line_list,
|
||||
icon: 'return_orders'
|
||||
},
|
||||
address: {
|
||||
label: () => t`Address`,
|
||||
label_multiple: () => t`Addresses`,
|
||||
url_overview: '/address',
|
||||
url_detail: '/address/:pk/',
|
||||
api_endpoint: ApiEndpoints.address_list
|
||||
api_endpoint: ApiEndpoints.address_list,
|
||||
icon: 'address'
|
||||
},
|
||||
contact: {
|
||||
label: () => t`Contact`,
|
||||
label_multiple: () => t`Contacts`,
|
||||
url_overview: '/contact',
|
||||
url_detail: '/contact/:pk/',
|
||||
api_endpoint: ApiEndpoints.contact_list
|
||||
api_endpoint: ApiEndpoints.contact_list,
|
||||
icon: 'group'
|
||||
},
|
||||
owner: {
|
||||
label: () => t`Owner`,
|
||||
label_multiple: () => t`Owners`,
|
||||
url_overview: '/owner',
|
||||
url_detail: '/owner/:pk/',
|
||||
api_endpoint: ApiEndpoints.owner_list
|
||||
api_endpoint: ApiEndpoints.owner_list,
|
||||
icon: 'group'
|
||||
},
|
||||
user: {
|
||||
label: () => t`User`,
|
||||
label_multiple: () => t`Users`,
|
||||
url_overview: '/user',
|
||||
url_detail: '/user/:pk/',
|
||||
api_endpoint: ApiEndpoints.user_list
|
||||
api_endpoint: ApiEndpoints.user_list,
|
||||
icon: 'user'
|
||||
},
|
||||
group: {
|
||||
label: () => t`Group`,
|
||||
@ -218,47 +245,54 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_overview: '/user/group',
|
||||
url_detail: '/user/group-:pk',
|
||||
api_endpoint: ApiEndpoints.group_list,
|
||||
admin_url: '/auth/group/'
|
||||
admin_url: '/auth/group/',
|
||||
icon: 'group'
|
||||
},
|
||||
importsession: {
|
||||
label: () => t`Import Session`,
|
||||
label_multiple: () => t`Import Sessions`,
|
||||
url_overview: '/import',
|
||||
url_detail: '/import/:pk/',
|
||||
api_endpoint: ApiEndpoints.import_session_list
|
||||
api_endpoint: ApiEndpoints.import_session_list,
|
||||
icon: 'import'
|
||||
},
|
||||
labeltemplate: {
|
||||
label: () => t`Label Template`,
|
||||
label_multiple: () => t`Label Templates`,
|
||||
url_overview: '/labeltemplate',
|
||||
url_detail: '/labeltemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.label_list
|
||||
api_endpoint: ApiEndpoints.label_list,
|
||||
icon: 'labels'
|
||||
},
|
||||
reporttemplate: {
|
||||
label: () => t`Report Template`,
|
||||
label_multiple: () => t`Report Templates`,
|
||||
url_overview: '/reporttemplate',
|
||||
url_detail: '/reporttemplate/:pk/',
|
||||
api_endpoint: ApiEndpoints.report_list
|
||||
api_endpoint: ApiEndpoints.report_list,
|
||||
icon: 'reports'
|
||||
},
|
||||
pluginconfig: {
|
||||
label: () => t`Plugin Configuration`,
|
||||
label_multiple: () => t`Plugin Configurations`,
|
||||
url_overview: '/pluginconfig',
|
||||
url_detail: '/pluginconfig/:pk/',
|
||||
api_endpoint: ApiEndpoints.plugin_list
|
||||
api_endpoint: ApiEndpoints.plugin_list,
|
||||
icon: 'plugin'
|
||||
},
|
||||
contenttype: {
|
||||
label: () => t`Content Type`,
|
||||
label_multiple: () => t`Content Types`,
|
||||
api_endpoint: ApiEndpoints.content_type_list
|
||||
api_endpoint: ApiEndpoints.content_type_list,
|
||||
icon: 'list_details'
|
||||
},
|
||||
error: {
|
||||
label: () => t`Error`,
|
||||
label_multiple: () => t`Errors`,
|
||||
api_endpoint: ApiEndpoints.error_report_list,
|
||||
url_overview: '/settings/admin/errors',
|
||||
url_detail: '/settings/admin/errors/:pk/'
|
||||
url_detail: '/settings/admin/errors/:pk/',
|
||||
icon: 'exclamation'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1,29 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { SimpleGrid, Title } from '@mantine/core';
|
||||
|
||||
import { ColorToggle } from '../items/ColorToggle';
|
||||
import { LanguageSelect } from '../items/LanguageSelect';
|
||||
|
||||
export default function DisplayWidget() {
|
||||
return (
|
||||
<span>
|
||||
<Title order={5}>
|
||||
<Trans>Display Settings</Trans>
|
||||
</Title>
|
||||
<SimpleGrid cols={2} spacing={0}>
|
||||
<div>
|
||||
<Trans>Color Mode</Trans>
|
||||
</div>
|
||||
<div>
|
||||
<ColorToggle />
|
||||
</div>
|
||||
<div>
|
||||
<Trans>Language</Trans>
|
||||
</div>
|
||||
<div>
|
||||
<LanguageSelect width={140} />
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</span>
|
||||
);
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Button, Stack, Title, useMantineColorScheme } from '@mantine/core';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
|
||||
import { vars } from '../../theme';
|
||||
|
||||
export default function FeedbackWidget() {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
return (
|
||||
<Stack
|
||||
style={{
|
||||
backgroundColor:
|
||||
colorScheme === 'dark' ? vars.colors.gray[9] : vars.colors.gray[1],
|
||||
borderRadius: vars.radius.md
|
||||
}}
|
||||
p={15}
|
||||
>
|
||||
<Title order={5}>
|
||||
<Trans>Something is new: Platform UI</Trans>
|
||||
</Title>
|
||||
<Trans>
|
||||
We are building a new UI with a modern stack. What you currently see is
|
||||
not fixed and will be redesigned but demonstrates the UI/UX
|
||||
possibilities we will have going forward.
|
||||
</Trans>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://github.com/inventree/InvenTree/discussions/5328"
|
||||
variant="outline"
|
||||
leftSection={<IconExternalLink size="0.9rem" />}
|
||||
>
|
||||
<Trans>Provide Feedback</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Title } from '@mantine/core';
|
||||
|
||||
import { navDocLinks } from '../../defaults/links';
|
||||
import { GettingStartedCarousel } from '../items/GettingStartedCarousel';
|
||||
|
||||
export default function GetStartedWidget() {
|
||||
return (
|
||||
<span>
|
||||
<Title order={5}>
|
||||
<Trans>Getting Started</Trans>
|
||||
</Title>
|
||||
<GettingStartedCarousel items={navDocLinks} />
|
||||
</span>
|
||||
);
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { vars } from '../../theme';
|
||||
|
||||
export const backgroundItem = style({
|
||||
maxWidth: '100%',
|
||||
padding: '8px',
|
||||
boxShadow: vars.shadows.md,
|
||||
[vars.lightSelector]: { backgroundColor: vars.colors.white },
|
||||
[vars.darkSelector]: { backgroundColor: vars.colors.dark[5] }
|
||||
});
|
||||
|
||||
export const baseItem = style({
|
||||
maxWidth: '100%',
|
||||
padding: '8px'
|
||||
});
|
@ -1,232 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Container,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import {
|
||||
IconArrowBackUpDouble,
|
||||
IconDotsVertical,
|
||||
IconLayout2,
|
||||
IconSquare,
|
||||
IconSquareCheck
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
|
||||
import * as classes from './WidgetLayout.css';
|
||||
|
||||
const ReactGridLayout = WidthProvider(Responsive);
|
||||
|
||||
interface LayoutStorage {
|
||||
[key: string]: {};
|
||||
}
|
||||
|
||||
const compactType = 'vertical';
|
||||
|
||||
export interface LayoutItemType {
|
||||
i: number;
|
||||
val: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
|
||||
w?: number;
|
||||
h?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export function WidgetLayout({
|
||||
items = [],
|
||||
className = 'layout',
|
||||
localstorageName = 'argl',
|
||||
rowHeight = 30
|
||||
}: Readonly<{
|
||||
items: LayoutItemType[];
|
||||
className?: string;
|
||||
localstorageName?: string;
|
||||
rowHeight?: number;
|
||||
}>) {
|
||||
const [layouts, setLayouts] = useState({});
|
||||
const [editable, setEditable] = useDisclosure(false);
|
||||
const [boxShown, setBoxShown] = useDisclosure(true);
|
||||
|
||||
useEffect(() => {
|
||||
let layout = getFromLS('layouts') || [];
|
||||
const new_layout = JSON.parse(JSON.stringify(layout));
|
||||
setLayouts(new_layout);
|
||||
}, []);
|
||||
|
||||
function getFromLS(key: string) {
|
||||
let ls: LayoutStorage = {};
|
||||
if (localStorage) {
|
||||
try {
|
||||
ls = JSON.parse(localStorage.getItem(localstorageName) || '') || {};
|
||||
} catch (e) {
|
||||
/*Ignore*/
|
||||
}
|
||||
}
|
||||
return ls[key];
|
||||
}
|
||||
|
||||
function saveToLS(key: string, value: any) {
|
||||
if (localStorage) {
|
||||
localStorage.setItem(
|
||||
localstorageName,
|
||||
JSON.stringify({
|
||||
[key]: value
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
setLayouts({});
|
||||
}
|
||||
|
||||
function onLayoutChange(layout: any, layouts: any) {
|
||||
saveToLS('layouts', layouts);
|
||||
setLayouts(layouts);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WidgetControlBar
|
||||
editable={editable}
|
||||
editFnc={setEditable.toggle}
|
||||
resetLayout={resetLayout}
|
||||
boxShown={boxShown}
|
||||
boxFnc={setBoxShown.toggle}
|
||||
/>
|
||||
{layouts ? (
|
||||
<ReactGridLayout
|
||||
className={className}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={rowHeight}
|
||||
layouts={layouts}
|
||||
onLayoutChange={(layout, layouts) => onLayoutChange(layout, layouts)}
|
||||
compactType={compactType}
|
||||
isDraggable={editable}
|
||||
isResizable={editable}
|
||||
>
|
||||
{items.map((item) => {
|
||||
return LayoutItem(item, boxShown, classes);
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
) : (
|
||||
<div>
|
||||
<Trans>Loading</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetControlBar({
|
||||
editable,
|
||||
editFnc,
|
||||
resetLayout,
|
||||
boxShown,
|
||||
boxFnc
|
||||
}: Readonly<{
|
||||
editable: boolean;
|
||||
editFnc: () => void;
|
||||
resetLayout: () => void;
|
||||
boxShown: boolean;
|
||||
boxFnc: () => void;
|
||||
}>) {
|
||||
useHotkeys([['mod+E', () => editFnc()]]);
|
||||
|
||||
return (
|
||||
<Group justify="right">
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={200}
|
||||
openDelay={100}
|
||||
closeDelay={400}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
color="red"
|
||||
position="bottom-start"
|
||||
processing
|
||||
disabled={!editable}
|
||||
>
|
||||
<ActionIcon variant="transparent">
|
||||
<IconDotsVertical />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Trans>Layout</Trans>
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowBackUpDouble size={14} />}
|
||||
onClick={resetLayout}
|
||||
>
|
||||
<Trans>Reset Layout</Trans>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconLayout2 size={14} color={editable ? 'red' : undefined} />
|
||||
}
|
||||
onClick={editFnc}
|
||||
rightSection={
|
||||
<Text size="xs" c="dimmed">
|
||||
⌘E
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{editable ? <Trans>Stop Edit</Trans> : <Trans>Edit Layout</Trans>}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>
|
||||
<Trans>Appearance</Trans>
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
boxShown ? (
|
||||
<IconSquareCheck size={14} />
|
||||
) : (
|
||||
<IconSquare size={14} />
|
||||
)
|
||||
}
|
||||
onClick={boxFnc}
|
||||
>
|
||||
<Trans>Show Boxes</Trans>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutItem(
|
||||
item: any,
|
||||
backgroundColor: boolean,
|
||||
classes: { backgroundItem: string; baseItem: string }
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
key={item.i}
|
||||
data-grid={{
|
||||
w: item.w || 3,
|
||||
h: item.h || 3,
|
||||
x: item.x || 0,
|
||||
y: item.y || 0,
|
||||
minH: item.minH || undefined,
|
||||
minW: item.minW || undefined
|
||||
}}
|
||||
className={backgroundColor ? classes.backgroundItem : classes.baseItem}
|
||||
>
|
||||
{item.val}
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -6,25 +6,17 @@ import { NavigateFunction } from 'react-router-dom';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { useUserState } from '../states/UserState';
|
||||
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
|
||||
import { menuItems } from './menuItems';
|
||||
|
||||
export function getActions(navigate: NavigateFunction) {
|
||||
const setNavigationOpen = useLocalState((state) => state.setNavigationOpen);
|
||||
const { user } = useUserState();
|
||||
|
||||
const actions: SpotlightActionData[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: t`Home`,
|
||||
description: `Go to the home page`,
|
||||
onClick: () => navigate(menuItems.home.link),
|
||||
leftSection: <IconHome size="1.2rem" />
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: t`Dashboard`,
|
||||
description: t`Go to the InvenTree dashboard`,
|
||||
onClick: () => navigate(menuItems.dashboard.link),
|
||||
onClick: () => {}, // navigate(menuItems.dashboard.link),
|
||||
leftSection: <IconLink size="1.2rem" />
|
||||
},
|
||||
{
|
||||
@ -70,7 +62,7 @@ export function getActions(navigate: NavigateFunction) {
|
||||
id: 'admin-center',
|
||||
label: t`Admin Center`,
|
||||
description: t`Go to the Admin Center`,
|
||||
onClick: () => navigate(menuItems['settings-admin'].link),
|
||||
onClick: () => {}, /// navigate(menuItems['settings-admin'].link),}
|
||||
leftSection: <IconLink size="1.2rem" />
|
||||
});
|
||||
|
||||
|
@ -1,132 +0,0 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
|
||||
interface DashboardItems {
|
||||
id: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
url: ApiEndpoints;
|
||||
params: any;
|
||||
}
|
||||
export const dashboardItems: DashboardItems[] = [
|
||||
{
|
||||
id: 'starred-parts',
|
||||
text: t`Subscribed Parts`,
|
||||
icon: 'fa-bell',
|
||||
url: ApiEndpoints.part_list,
|
||||
params: { starred: true }
|
||||
},
|
||||
{
|
||||
id: 'starred-categories',
|
||||
text: t`Subscribed Categories`,
|
||||
icon: 'fa-bell',
|
||||
url: ApiEndpoints.category_list,
|
||||
params: { starred: true }
|
||||
},
|
||||
{
|
||||
id: 'latest-parts',
|
||||
text: t`Latest Parts`,
|
||||
icon: 'fa-newspaper',
|
||||
url: ApiEndpoints.part_list,
|
||||
params: { ordering: '-creation_date', limit: 10 }
|
||||
},
|
||||
{
|
||||
id: 'bom-validation',
|
||||
text: t`BOM Waiting Validation`,
|
||||
icon: 'fa-times-circle',
|
||||
url: ApiEndpoints.part_list,
|
||||
params: { bom_valid: false }
|
||||
},
|
||||
{
|
||||
id: 'recently-updated-stock',
|
||||
text: t`Recently Updated`,
|
||||
icon: 'fa-clock',
|
||||
url: ApiEndpoints.stock_item_list,
|
||||
params: { part_detail: true, ordering: '-updated', limit: 10 }
|
||||
},
|
||||
{
|
||||
id: 'low-stock',
|
||||
text: t`Low Stock`,
|
||||
icon: 'fa-flag',
|
||||
url: ApiEndpoints.part_list,
|
||||
params: { low_stock: true }
|
||||
},
|
||||
{
|
||||
id: 'depleted-stock',
|
||||
text: t`Depleted Stock`,
|
||||
icon: 'fa-times',
|
||||
url: ApiEndpoints.part_list,
|
||||
params: { depleted_stock: true }
|
||||
},
|
||||
{
|
||||
id: 'stock-to-build',
|
||||
text: t`Required for Build Orders`,
|
||||
icon: 'fa-bullhorn',
|
||||
url: ApiEndpoints.part_list,
|
||||
params: { stock_to_build: true }
|
||||
},
|
||||
{
|
||||
id: 'expired-stock',
|
||||
text: t`Expired Stock`,
|
||||
icon: 'fa-calendar-times',
|
||||
url: ApiEndpoints.stock_item_list,
|
||||
params: { expired: true }
|
||||
},
|
||||
{
|
||||
id: 'stale-stock',
|
||||
text: t`Stale Stock`,
|
||||
icon: 'fa-stopwatch',
|
||||
url: ApiEndpoints.stock_item_list,
|
||||
params: { stale: true, expired: true }
|
||||
},
|
||||
{
|
||||
id: 'build-pending',
|
||||
text: t`Build Orders In Progress`,
|
||||
icon: 'fa-cogs',
|
||||
url: ApiEndpoints.build_order_list,
|
||||
params: { active: true }
|
||||
},
|
||||
{
|
||||
id: 'build-overdue',
|
||||
text: t`Overdue Build Orders`,
|
||||
icon: 'fa-calendar-times',
|
||||
url: ApiEndpoints.build_order_list,
|
||||
params: { overdue: true }
|
||||
},
|
||||
{
|
||||
id: 'po-outstanding',
|
||||
text: t`Outstanding Purchase Orders`,
|
||||
icon: 'fa-sign-in-alt',
|
||||
url: ApiEndpoints.purchase_order_list,
|
||||
params: { supplier_detail: true, outstanding: true }
|
||||
},
|
||||
{
|
||||
id: 'po-overdue',
|
||||
text: t`Overdue Purchase Orders`,
|
||||
icon: 'fa-calendar-times',
|
||||
url: ApiEndpoints.purchase_order_list,
|
||||
params: { supplier_detail: true, overdue: true }
|
||||
},
|
||||
{
|
||||
id: 'so-outstanding',
|
||||
text: t`Outstanding Sales Orders`,
|
||||
icon: 'fa-sign-out-alt',
|
||||
url: ApiEndpoints.sales_order_list,
|
||||
params: { customer_detail: true, outstanding: true }
|
||||
},
|
||||
{
|
||||
id: 'so-overdue',
|
||||
text: t`Overdue Sales Orders`,
|
||||
icon: 'fa-calendar-times',
|
||||
url: ApiEndpoints.sales_order_list,
|
||||
params: { customer_detail: true, overdue: true }
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
text: t`Current News`,
|
||||
icon: 'fa-newspaper',
|
||||
url: ApiEndpoints.news,
|
||||
params: {}
|
||||
}
|
||||
];
|
@ -1,31 +1,12 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
|
||||
import { DocumentationLinkItem } from '../components/items/DocumentationLinks';
|
||||
import { MenuLinkItem } from '../components/items/MenuLinks';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { UserRoles } from '../enums/Roles';
|
||||
import { IS_DEV_OR_DEMO } from '../main';
|
||||
|
||||
export const footerLinks = [
|
||||
{
|
||||
link: 'https://inventree.org/',
|
||||
label: <Trans>Website</Trans>,
|
||||
key: 'website'
|
||||
},
|
||||
{
|
||||
link: 'https://github.com/inventree/InvenTree',
|
||||
label: <Trans>GitHub</Trans>,
|
||||
key: 'github'
|
||||
},
|
||||
{
|
||||
link: 'https://demo.inventree.org/',
|
||||
label: <Trans>Demo</Trans>,
|
||||
key: 'demo'
|
||||
}
|
||||
];
|
||||
export const navTabs = [
|
||||
{ text: <Trans>Home</Trans>, name: 'home' },
|
||||
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
|
||||
{ text: <Trans>Dashboard</Trans>, name: 'home' },
|
||||
{ text: <Trans>Parts</Trans>, name: 'part', role: UserRoles.part },
|
||||
{ text: <Trans>Stock</Trans>, name: 'stock', role: UserRoles.stock },
|
||||
{
|
||||
@ -43,39 +24,52 @@ export const navTabs = [
|
||||
|
||||
export const docLinks = {
|
||||
app: 'https://docs.inventree.org/app/',
|
||||
getting_started: 'https://docs.inventree.org/en/latest/getting_started/',
|
||||
getting_started: 'https://docs.inventree.org/en/latest/start/intro/',
|
||||
api: 'https://docs.inventree.org/en/latest/api/api/',
|
||||
developer: 'https://docs.inventree.org/en/latest/develop/starting/',
|
||||
faq: 'https://docs.inventree.org/en/latest/faq/'
|
||||
developer: 'https://docs.inventree.org/en/latest/develop/contributing/',
|
||||
faq: 'https://docs.inventree.org/en/latest/faq/',
|
||||
github: 'https://github.com/inventree/inventree'
|
||||
};
|
||||
|
||||
export const navDocLinks: DocumentationLinkItem[] = [
|
||||
{
|
||||
id: 'getting_started',
|
||||
title: <Trans>Getting Started</Trans>,
|
||||
description: <Trans>Getting started with InvenTree</Trans>,
|
||||
link: docLinks.getting_started,
|
||||
placeholder: true
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
title: <Trans>API</Trans>,
|
||||
description: <Trans>InvenTree API documentation</Trans>,
|
||||
link: docLinks.api
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
title: <Trans>Developer Manual</Trans>,
|
||||
description: <Trans>InvenTree developer manual</Trans>,
|
||||
link: docLinks.developer
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
title: <Trans>FAQ</Trans>,
|
||||
description: <Trans>Frequently asked questions</Trans>,
|
||||
link: docLinks.faq
|
||||
}
|
||||
];
|
||||
export function DocumentationLinks(): MenuLinkItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'gettin-started',
|
||||
title: t`Getting Started`,
|
||||
link: docLinks.getting_started,
|
||||
external: true,
|
||||
description: t`Getting started with InvenTree`
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
title: t`API`,
|
||||
link: docLinks.api,
|
||||
external: true,
|
||||
description: t`InvenTree API documentation`
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
title: t`Developer Manual`,
|
||||
link: docLinks.developer,
|
||||
external: true,
|
||||
description: t`InvenTree developer manual`
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
title: t`FAQ`,
|
||||
link: docLinks.faq,
|
||||
external: true,
|
||||
description: t`Frequently asked questions`
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
title: t`GitHub Repository`,
|
||||
link: docLinks.github,
|
||||
external: true,
|
||||
description: t`InvenTree source code on GitHub`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function serverInfo() {
|
||||
return openContextModal({
|
||||
@ -116,23 +110,28 @@ export function licenseInfo() {
|
||||
});
|
||||
}
|
||||
|
||||
export const aboutLinks: DocumentationLinkItem[] = [
|
||||
{
|
||||
id: 'instance',
|
||||
title: <Trans>System Information</Trans>,
|
||||
description: <Trans>About this Inventree instance</Trans>,
|
||||
action: serverInfo
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
title: <Trans>About InvenTree</Trans>,
|
||||
description: <Trans>About the InvenTree org</Trans>,
|
||||
action: aboutInvenTree
|
||||
},
|
||||
{
|
||||
id: 'licenses',
|
||||
title: <Trans>Licenses</Trans>,
|
||||
description: <Trans>Licenses for dependencies of the service</Trans>,
|
||||
action: licenseInfo
|
||||
}
|
||||
];
|
||||
export function AboutLinks(): MenuLinkItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'instance',
|
||||
title: t`System Information`,
|
||||
description: t`About this Inventree instance`,
|
||||
icon: 'info',
|
||||
action: serverInfo
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
title: t`About InvenTree`,
|
||||
description: t`About the InvenTree Project`,
|
||||
icon: 'info',
|
||||
action: aboutInvenTree
|
||||
},
|
||||
{
|
||||
id: 'licenses',
|
||||
title: t`License Information`,
|
||||
description: t`Licenses for dependencies of the InvenTree software`,
|
||||
icon: 'license',
|
||||
action: licenseInfo
|
||||
}
|
||||
];
|
||||
}
|
||||
|
@ -1,66 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { menuItemsCollection } from '../components/items/MenuLinks';
|
||||
import { IS_DEV_OR_DEMO } from '../main';
|
||||
|
||||
export const menuItems: menuItemsCollection = {
|
||||
home: {
|
||||
id: 'home',
|
||||
text: <Trans>Home</Trans>,
|
||||
link: '/',
|
||||
highlight: true
|
||||
},
|
||||
profile: {
|
||||
id: 'profile',
|
||||
text: <Trans>Account Settings</Trans>,
|
||||
link: '/settings/user',
|
||||
doctext: <Trans>User attributes and design settings.</Trans>
|
||||
},
|
||||
scan: {
|
||||
id: 'scan',
|
||||
text: <Trans>Scanning</Trans>,
|
||||
link: '/scan',
|
||||
doctext: <Trans>View for interactive scanning and multiple actions.</Trans>,
|
||||
highlight: true
|
||||
},
|
||||
dashboard: {
|
||||
id: 'dashboard',
|
||||
text: <Trans>Dashboard</Trans>,
|
||||
link: '/dashboard'
|
||||
},
|
||||
parts: {
|
||||
id: 'parts',
|
||||
text: <Trans>Parts</Trans>,
|
||||
link: '/part/'
|
||||
},
|
||||
stock: {
|
||||
id: 'stock',
|
||||
text: <Trans>Stock</Trans>,
|
||||
link: '/stock'
|
||||
},
|
||||
build: {
|
||||
id: 'manufacturing',
|
||||
text: <Trans>Manufacturing</Trans>,
|
||||
link: '/manufacturing/'
|
||||
},
|
||||
purchasing: {
|
||||
id: 'purchasing',
|
||||
text: <Trans>Purchasing</Trans>,
|
||||
link: '/purchasing/'
|
||||
},
|
||||
sales: {
|
||||
id: 'sales',
|
||||
text: <Trans>Sales</Trans>,
|
||||
link: '/sales/'
|
||||
},
|
||||
'settings-system': {
|
||||
id: 'settings-system',
|
||||
text: <Trans>System Settings</Trans>,
|
||||
link: '/settings/system'
|
||||
},
|
||||
'settings-admin': {
|
||||
id: 'settings-admin',
|
||||
text: <Trans>Admin Center</Trans>,
|
||||
link: '/settings/admin'
|
||||
}
|
||||
};
|
@ -201,7 +201,6 @@ export enum ApiEndpoints {
|
||||
plugin_admin = 'plugins/:key/admin/',
|
||||
|
||||
// User interface plugin endpoints
|
||||
plugin_panel_list = 'plugins/ui/panels/',
|
||||
plugin_ui_features_list = 'plugins/ui/features/:feature_type/',
|
||||
|
||||
// Machine API endpoints
|
||||
|
@ -35,6 +35,7 @@ import {
|
||||
IconEdit,
|
||||
IconExclamationCircle,
|
||||
IconExternalLink,
|
||||
IconFileArrowLeft,
|
||||
IconFileDownload,
|
||||
IconFileUpload,
|
||||
IconFlag,
|
||||
@ -44,13 +45,18 @@ import {
|
||||
IconHandStop,
|
||||
IconHash,
|
||||
IconHierarchy,
|
||||
IconHistory,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconLayoutDashboard,
|
||||
IconLicense,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconListDetails,
|
||||
IconListTree,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconMap2,
|
||||
IconMapPin,
|
||||
IconMapPinHeart,
|
||||
IconMinusVertical,
|
||||
@ -71,6 +77,7 @@ import {
|
||||
IconQuestionMark,
|
||||
IconRefresh,
|
||||
IconRulerMeasure,
|
||||
IconSettings,
|
||||
IconShoppingCart,
|
||||
IconShoppingCartHeart,
|
||||
IconShoppingCartPlus,
|
||||
@ -90,6 +97,7 @@ import {
|
||||
IconTruckReturn,
|
||||
IconUnlink,
|
||||
IconUser,
|
||||
IconUserBolt,
|
||||
IconUserStar,
|
||||
IconUsersGroup,
|
||||
IconVersions,
|
||||
@ -124,6 +132,7 @@ const icons = {
|
||||
details: IconInfoCircle,
|
||||
parameters: IconList,
|
||||
list: IconList,
|
||||
list_details: IconListDetails,
|
||||
stock: IconPackages,
|
||||
variants: IconVersions,
|
||||
allocations: IconBookmarks,
|
||||
@ -163,8 +172,13 @@ const icons = {
|
||||
issue: IconBrandTelegram,
|
||||
complete: IconCircleCheck,
|
||||
deliver: IconTruckDelivery,
|
||||
address: IconMap2,
|
||||
import: IconFileArrowLeft,
|
||||
bell: IconBell,
|
||||
notification: IconBell,
|
||||
admin: IconUserBolt,
|
||||
system: IconSettings,
|
||||
license: IconLicense,
|
||||
|
||||
// Part Icons
|
||||
active: IconCheck,
|
||||
@ -210,6 +224,7 @@ const icons = {
|
||||
arrow_down: IconArrowBigDownLineFilled,
|
||||
transfer: IconTransfer,
|
||||
actions: IconDots,
|
||||
labels: IconTag,
|
||||
reports: IconPrinter,
|
||||
buy: IconShoppingCartPlus,
|
||||
add: IconCirclePlus,
|
||||
@ -236,7 +251,9 @@ const icons = {
|
||||
repeat_destination: IconFlagShare,
|
||||
unlink: IconUnlink,
|
||||
success: IconCircleCheck,
|
||||
plugin: IconPlug
|
||||
plugin: IconPlug,
|
||||
history: IconHistory,
|
||||
dashboard: IconLayoutDashboard
|
||||
};
|
||||
|
||||
export type InvenTreeIconType = keyof typeof icons;
|
||||
@ -248,8 +265,8 @@ export type TablerIconType = React.ForwardRefExoticComponent<
|
||||
* Returns a Tabler Icon for the model field name supplied
|
||||
* @param field string defining field name
|
||||
*/
|
||||
export function GetIcon(field: InvenTreeIconType) {
|
||||
return icons[field];
|
||||
export function GetIcon(field: string): TablerIconType {
|
||||
return icons[field as InvenTreeIconType];
|
||||
}
|
||||
|
||||
// Aliasing the new type name to make it distinct
|
||||
|
116
src/frontend/src/hooks/UseDashboardItems.tsx
Normal file
116
src/frontend/src/hooks/UseDashboardItems.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { DashboardWidgetProps } from '../components/dashboard/DashboardWidget';
|
||||
import DashboardWidgetLibrary from '../components/dashboard/DashboardWidgetLibrary';
|
||||
import { useInvenTreeContext } from '../components/plugins/PluginContext';
|
||||
import {
|
||||
PluginUIFeature,
|
||||
PluginUIFeatureType
|
||||
} from '../components/plugins/PluginUIFeature';
|
||||
import RemoteComponent from '../components/plugins/RemoteComponent';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { identifierString } from '../functions/conversion';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
import { useUserState } from '../states/UserState';
|
||||
|
||||
interface DashboardLibraryProps {
|
||||
items: DashboardWidgetProps[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to load available dashboard items.
|
||||
*
|
||||
* - Loads from library of "builtin" dashboard items
|
||||
* - Loads plugin-defined dashboard items (via the API)
|
||||
*/
|
||||
export function useDashboardItems(): DashboardLibraryProps {
|
||||
const user = useUserState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const pluginsEnabled: boolean = useMemo(
|
||||
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||
[globalSettings]
|
||||
);
|
||||
|
||||
const builtin = DashboardWidgetLibrary();
|
||||
|
||||
const pluginQuery = useQuery({
|
||||
enabled: pluginsEnabled,
|
||||
queryKey: ['plugin-dashboard-items', user],
|
||||
refetchOnMount: true,
|
||||
queryFn: async () => {
|
||||
if (!pluginsEnabled) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
|
||||
feature_type: PluginUIFeatureType.dashboard
|
||||
});
|
||||
|
||||
return api
|
||||
.get(url)
|
||||
.then((response: any) => response.data)
|
||||
.catch((_error: any) => {
|
||||
console.error('ERR: Failed to fetch plugin dashboard items');
|
||||
return [];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the context data which is delivered to the plugins
|
||||
const inventreeContext = useInvenTreeContext();
|
||||
|
||||
const pluginDashboardItems: DashboardWidgetProps[] = useMemo(() => {
|
||||
return (
|
||||
pluginQuery?.data?.map((item: PluginUIFeature) => {
|
||||
const pluginContext = {
|
||||
...inventreeContext,
|
||||
context: item.context
|
||||
};
|
||||
|
||||
return {
|
||||
label: identifierString(`p-${item.plugin_name}-${item.key}`),
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
minWidth: item.options?.width ?? 2,
|
||||
minHeight: item.options?.height ?? 1,
|
||||
render: () => {
|
||||
return (
|
||||
<RemoteComponent
|
||||
source={item.source}
|
||||
defaultFunctionName="renderDashboardItem"
|
||||
context={pluginContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [pluginQuery, inventreeContext]);
|
||||
|
||||
const items: DashboardWidgetProps[] = useMemo(() => {
|
||||
return [...builtin, ...pluginDashboardItems];
|
||||
}, [builtin, pluginDashboardItems]);
|
||||
|
||||
const loaded: boolean = useMemo(() => {
|
||||
if (pluginsEnabled) {
|
||||
return (
|
||||
!pluginQuery.isFetching &&
|
||||
!pluginQuery.isLoading &&
|
||||
pluginQuery.isFetched &&
|
||||
pluginQuery.isSuccess
|
||||
);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}, [pluginsEnabled, pluginQuery]);
|
||||
|
||||
return {
|
||||
items: items,
|
||||
loaded: loaded
|
||||
};
|
||||
}
|
@ -1,10 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { QueryObserverResult, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { PathParams, apiUrl } from '../states/ApiState';
|
||||
|
||||
export interface UseInstanceResult {
|
||||
instance: any;
|
||||
setInstance: (instance: any) => void;
|
||||
refreshInstance: () => Promise<QueryObserverResult<any, any>>;
|
||||
instanceQuery: any;
|
||||
requestStatus: number;
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for loading a single instance of an instance from the API
|
||||
*
|
||||
@ -36,7 +45,7 @@ export function useInstance<T = any>({
|
||||
refetchOnWindowFocus?: boolean;
|
||||
throwError?: boolean;
|
||||
updateInterval?: number;
|
||||
}) {
|
||||
}): UseInstanceResult {
|
||||
const [instance, setInstance] = useState<T | undefined>(defaultValue);
|
||||
|
||||
const [requestStatus, setRequestStatus] = useState<number>(0);
|
||||
@ -95,6 +104,14 @@ export function useInstance<T = any>({
|
||||
refetchInterval: updateInterval
|
||||
});
|
||||
|
||||
const isLoaded = useMemo(() => {
|
||||
return (
|
||||
instanceQuery.isFetched &&
|
||||
instanceQuery.isSuccess &&
|
||||
!instanceQuery.isError
|
||||
);
|
||||
}, [instanceQuery]);
|
||||
|
||||
const refreshInstance = useCallback(function () {
|
||||
return instanceQuery.refetch();
|
||||
}, []);
|
||||
@ -104,6 +121,7 @@ export function useInstance<T = any>({
|
||||
setInstance,
|
||||
refreshInstance,
|
||||
instanceQuery,
|
||||
requestStatus
|
||||
requestStatus,
|
||||
isLoaded
|
||||
};
|
||||
}
|
||||
|
14
src/frontend/src/hooks/UseInstanceName.tsx
Normal file
14
src/frontend/src/hooks/UseInstanceName.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
|
||||
/**
|
||||
* Simple hook for returning the "instance name" of the Server
|
||||
*/
|
||||
export default function useInstanceName(): string {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
return useMemo(() => {
|
||||
return globalSettings.getSetting('INVENTREE_INSTANCE', 'InvenTree');
|
||||
}, [globalSettings]);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { PanelType } from '../components/panels/Panel';
|
||||
@ -7,10 +7,11 @@ import {
|
||||
InvenTreeContext,
|
||||
useInvenTreeContext
|
||||
} from '../components/plugins/PluginContext';
|
||||
import PluginPanelContent, {
|
||||
PluginPanelProps,
|
||||
isPluginPanelHidden
|
||||
} from '../components/plugins/PluginPanel';
|
||||
import PluginPanelContent from '../components/plugins/PluginPanel';
|
||||
import {
|
||||
PluginUIFeature,
|
||||
PluginUIFeatureType
|
||||
} from '../components/plugins/PluginUIFeature';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { identifierString } from '../functions/conversion';
|
||||
@ -54,16 +55,20 @@ export function usePluginPanels({
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
|
||||
feature_type: PluginUIFeatureType.panel
|
||||
});
|
||||
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.plugin_panel_list), {
|
||||
.get(url, {
|
||||
params: {
|
||||
target_model: model,
|
||||
target_id: id
|
||||
}
|
||||
})
|
||||
.then((response: any) => response.data)
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to fetch plugin panels:', error);
|
||||
.catch((_error: any) => {
|
||||
console.error(`ERR: Failed to fetch plugin panels`);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
@ -80,32 +85,13 @@ export function usePluginPanels({
|
||||
};
|
||||
}, [model, id, instance]);
|
||||
|
||||
// Track which panels are hidden: { panelName: true/false }
|
||||
// We need to memoize this as the plugins can determine this dynamically
|
||||
const [panelState, setPanelState] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Clear the visibility cache when the plugin data changes
|
||||
// This will force the plugin panels to re-calculate their visibility
|
||||
useEffect(() => {
|
||||
pluginData?.forEach((props: PluginPanelProps) => {
|
||||
const identifier = identifierString(`${props.plugin}-${props.name}`);
|
||||
|
||||
// Check if the panel is hidden (defaults to true until we know otherwise)
|
||||
isPluginPanelHidden({
|
||||
pluginProps: props,
|
||||
pluginContext: contextData
|
||||
}).then((result) => {
|
||||
setPanelState((prev) => ({ ...prev, [identifier]: result }));
|
||||
});
|
||||
});
|
||||
}, [pluginData, contextData]);
|
||||
|
||||
const pluginPanels: PanelType[] = useMemo(() => {
|
||||
return (
|
||||
pluginData?.map((props: PluginPanelProps) => {
|
||||
const iconName: string = props.icon || 'plugin';
|
||||
const identifier = identifierString(`${props.plugin}-${props.name}`);
|
||||
const isHidden: boolean = panelState[identifier] ?? true;
|
||||
pluginData?.map((props: PluginUIFeature) => {
|
||||
const iconName: string = props?.icon || 'plugin';
|
||||
const identifier = identifierString(
|
||||
`${props.plugin_name}-${props.key}`
|
||||
);
|
||||
|
||||
const pluginContext: any = {
|
||||
...contextData,
|
||||
@ -114,19 +100,18 @@ export function usePluginPanels({
|
||||
|
||||
return {
|
||||
name: identifier,
|
||||
label: props.label,
|
||||
label: props.title,
|
||||
icon: <InvenTreeIcon icon={iconName as InvenTreeIconType} />,
|
||||
content: (
|
||||
<PluginPanelContent
|
||||
pluginProps={props}
|
||||
pluginFeature={props}
|
||||
pluginContext={pluginContext}
|
||||
/>
|
||||
),
|
||||
hidden: isHidden
|
||||
)
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [panelState, pluginData, contextData]);
|
||||
}, [pluginData, contextData]);
|
||||
|
||||
return pluginPanels;
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ export function usePluginUIFeature<UIFeatureT extends BaseUIFeature>({
|
||||
.then((response: any) => response.data)
|
||||
.catch((error: any) => {
|
||||
console.error(
|
||||
`Failed to fetch plugin ui features for feature "${featureType}":`,
|
||||
`ERR: Failed to fetch plugin ui features for feature "${featureType}":`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
@ -70,21 +70,25 @@ export function usePluginUIFeature<UIFeatureT extends BaseUIFeature>({
|
||||
}[]
|
||||
>(() => {
|
||||
return (
|
||||
pluginData?.map((feature) => ({
|
||||
options: feature.options,
|
||||
func: (async (featureContext) => {
|
||||
const func = await findExternalPluginFunction(
|
||||
feature.source,
|
||||
'getFeature'
|
||||
);
|
||||
if (!func) return;
|
||||
pluginData?.map((feature) => {
|
||||
return {
|
||||
options: {
|
||||
...feature
|
||||
},
|
||||
func: (async (featureContext) => {
|
||||
const func = await findExternalPluginFunction(
|
||||
feature.source,
|
||||
'getFeature'
|
||||
);
|
||||
if (!func) return;
|
||||
|
||||
return func({
|
||||
featureContext,
|
||||
inventreeContext
|
||||
});
|
||||
}) as PluginUIFuncWithoutInvenTreeContextType<UIFeatureT>
|
||||
})) || []
|
||||
return func({
|
||||
featureContext,
|
||||
inventreeContext
|
||||
});
|
||||
}) as PluginUIFuncWithoutInvenTreeContextType<UIFeatureT>
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
}, [pluginData, inventreeContext]);
|
||||
}
|
||||
|
@ -82,21 +82,6 @@ export const link = style({
|
||||
}
|
||||
});
|
||||
|
||||
export const subLink = style({
|
||||
width: '100%',
|
||||
padding: `${vars.spacing.xs} ${vars.spacing.md}`,
|
||||
borderRadius: vars.radiusDefault,
|
||||
|
||||
':hover': {
|
||||
[vars.lightSelector]: { backgroundColor: vars.colors.gray[0] },
|
||||
[vars.darkSelector]: { backgroundColor: vars.colors.dark[7] }
|
||||
},
|
||||
|
||||
':active': {
|
||||
color: vars.colors.defaultHover
|
||||
}
|
||||
});
|
||||
|
||||
export const docHover = style({
|
||||
border: `1px dashed `
|
||||
});
|
||||
@ -106,24 +91,6 @@ export const layoutContent = style({
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
export const layoutFooterLinks = style({
|
||||
[vars.smallerThan('xs')]: {
|
||||
marginTop: vars.spacing.md
|
||||
}
|
||||
});
|
||||
|
||||
export const layoutFooterInner = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: vars.spacing.xl,
|
||||
paddingBottom: vars.spacing.xl,
|
||||
|
||||
[vars.smallerThan('xs')]: {
|
||||
flexDirection: 'column'
|
||||
}
|
||||
});
|
||||
|
||||
export const tabs = style({
|
||||
[vars.smallerThan('sm')]: {
|
||||
display: 'none'
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Chip, Group, SimpleGrid, Text } from '@mantine/core';
|
||||
|
||||
import { DashboardItemProxy } from '../../components/DashboardItemProxy';
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import { dashboardItems } from '../../defaults/dashboardItems';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [autoupdate, toggleAutoupdate] = useLocalState((state) => [
|
||||
state.autoupdate,
|
||||
state.toggleAutoupdate
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<StylishText>
|
||||
<Trans>Dashboard</Trans>
|
||||
</StylishText>
|
||||
<Chip checked={autoupdate} onChange={() => toggleAutoupdate()}>
|
||||
<Trans>Autoupdate</Trans>
|
||||
</Chip>
|
||||
</Group>
|
||||
<Text>
|
||||
<Trans>
|
||||
This page is a replacement for the old start page with the same
|
||||
information. This page will be deprecated and replaced by the home
|
||||
page.
|
||||
</Trans>
|
||||
</Text>
|
||||
<SimpleGrid cols={4} pt="md">
|
||||
{dashboardItems.map((item) => (
|
||||
<DashboardItemProxy key={item.id} {...item} autoupdate={autoupdate} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,63 +1,9 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Title } from '@mantine/core';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import {
|
||||
LayoutItemType,
|
||||
WidgetLayout
|
||||
} from '../../components/widgets/WidgetLayout';
|
||||
import { LoadingItem } from '../../functions/loading';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
const vals: LayoutItemType[] = [
|
||||
{
|
||||
i: 1,
|
||||
val: (
|
||||
<LoadingItem
|
||||
item={lazy(() => import('../../components/widgets/GetStartedWidget'))}
|
||||
/>
|
||||
),
|
||||
w: 12,
|
||||
h: 6,
|
||||
x: 0,
|
||||
y: 0,
|
||||
minH: 6
|
||||
},
|
||||
{
|
||||
i: 2,
|
||||
val: (
|
||||
<LoadingItem
|
||||
item={lazy(() => import('../../components/widgets/DisplayWidget'))}
|
||||
/>
|
||||
),
|
||||
w: 3,
|
||||
h: 3,
|
||||
x: 0,
|
||||
y: 7,
|
||||
minH: 3
|
||||
},
|
||||
{
|
||||
i: 4,
|
||||
val: (
|
||||
<LoadingItem
|
||||
item={lazy(() => import('../../components/widgets/FeedbackWidget'))}
|
||||
/>
|
||||
),
|
||||
w: 4,
|
||||
h: 6,
|
||||
x: 0,
|
||||
y: 9
|
||||
}
|
||||
];
|
||||
import DashboardLayout from '../../components/dashboard/DashboardLayout';
|
||||
|
||||
export default function Home() {
|
||||
const [username] = useUserState((state) => [state.username()]);
|
||||
return (
|
||||
<>
|
||||
<Title order={1}>
|
||||
<Trans>Welcome to your Dashboard{username && `, ${username}`}</Trans>
|
||||
</Title>
|
||||
<WidgetLayout items={vals} />
|
||||
<DashboardLayout />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -323,7 +323,7 @@ export default function CategoryDetail() {
|
||||
panels={panels}
|
||||
model={ModelType.partcategory}
|
||||
instance={category}
|
||||
id={category.pk}
|
||||
id={category.pk ?? null}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
|
@ -392,7 +392,7 @@ export default function Stock() {
|
||||
pageKey="stocklocation"
|
||||
panels={locationPanels}
|
||||
model={ModelType.stocklocation}
|
||||
id={location.pk}
|
||||
id={location.pk ?? null}
|
||||
instance={location}
|
||||
/>
|
||||
{transferStockItems.modal}
|
||||
|
@ -82,9 +82,6 @@ export const ReturnOrderDetail = Loadable(
|
||||
|
||||
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
|
||||
|
||||
export const Dashboard = Loadable(
|
||||
lazy(() => import('./pages/Index/Dashboard'))
|
||||
);
|
||||
export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
|
||||
|
||||
export const Notifications = Loadable(
|
||||
@ -121,7 +118,6 @@ export const routes = (
|
||||
<Route path="/" element={<LayoutComponent />} errorElement={<ErrorPage />}>
|
||||
<Route index element={<Home />} />,
|
||||
<Route path="home/" element={<Home />} />,
|
||||
<Route path="dashboard/" element={<Dashboard />} />,
|
||||
<Route path="notifications/*" element={<Notifications />} />,
|
||||
<Route path="scan/" element={<Scan />} />,
|
||||
<Route path="settings/">
|
||||
|
@ -11,6 +11,7 @@ import { UserProps } from './states';
|
||||
export interface UserStateProps {
|
||||
user: UserProps | undefined;
|
||||
token: string | undefined;
|
||||
userId: () => number | undefined;
|
||||
username: () => string;
|
||||
setUser: (newUser: UserProps) => void;
|
||||
setToken: (newToken: string) => void;
|
||||
@ -50,6 +51,10 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
||||
set({ token: undefined });
|
||||
setApiDefaults();
|
||||
},
|
||||
userId: () => {
|
||||
const user: UserProps = get().user as UserProps;
|
||||
return user.pk;
|
||||
},
|
||||
username: () => {
|
||||
const user: UserProps = get().user as UserProps;
|
||||
|
||||
|
@ -135,6 +135,14 @@ export default function InvenTreeTableHeader({
|
||||
/>
|
||||
</Boundary>
|
||||
)}
|
||||
{tableState.queryFilters.size > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
withCloseButton
|
||||
title={t`Custom table filters are active`}
|
||||
onClose={() => tableState.clearQueryFilters()}
|
||||
></Alert>
|
||||
)}
|
||||
|
||||
<Group justify="apart" grow wrap="nowrap">
|
||||
<Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
|
||||
@ -193,21 +201,6 @@ export default function InvenTreeTableHeader({
|
||||
onToggleColumn={toggleColumn}
|
||||
/>
|
||||
)}
|
||||
{tableState.queryFilters.size > 0 && (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red"
|
||||
aria-label="table-clear-query-filters"
|
||||
>
|
||||
<Tooltip label={t`Clear custom query filters`}>
|
||||
<IconFilterCancel
|
||||
onClick={() => {
|
||||
tableState.clearQueryFilters();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{tableProps.enableFilters && filters.length > 0 && (
|
||||
<Indicator
|
||||
size="xs"
|
||||
|
@ -115,10 +115,10 @@ export function BuildOrderTable({
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
let filters: TableFilter[] = [
|
||||
{
|
||||
name: 'active',
|
||||
name: 'outstanding',
|
||||
type: 'boolean',
|
||||
label: t`Active`,
|
||||
description: t`Show active orders`
|
||||
label: t`Outstanding`,
|
||||
description: t`Show outstanding orders`
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
|
@ -374,7 +374,7 @@ export default function PluginListTable() {
|
||||
{deletePluginModal.modal}
|
||||
{activatePluginModal.modal}
|
||||
<DetailDrawer
|
||||
title={t`Plugin Detail`}
|
||||
title={t`Plugin Detail` + ' - ' + selectedPlugin?.name}
|
||||
size={'65%'}
|
||||
renderContent={(pluginKey) => {
|
||||
if (!pluginKey) return;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconFileCode } from '@tabler/icons-react';
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
@ -27,6 +27,7 @@ import {
|
||||
} from '../../components/plugins/PluginUIFeatureTypes';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { GetIcon } from '../../functions/icons';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { useFilters } from '../../hooks/UseFilter';
|
||||
@ -94,7 +95,12 @@ export function TemplateDrawer({
|
||||
featureType: 'template_editor',
|
||||
context: { template_type: modelType, template_model: template?.model_type! }
|
||||
});
|
||||
|
||||
/**
|
||||
* List of available editors for the template
|
||||
*/
|
||||
const editors = useMemo(() => {
|
||||
// Always include the built-in code editor
|
||||
const editors = [CodeEditor];
|
||||
|
||||
if (!template) {
|
||||
@ -102,15 +108,16 @@ export function TemplateDrawer({
|
||||
}
|
||||
|
||||
editors.push(
|
||||
...(extraEditors?.map(
|
||||
(editor) =>
|
||||
({
|
||||
key: editor.options.key,
|
||||
name: editor.options.title,
|
||||
icon: GetIcon(editor.options.icon),
|
||||
component: getPluginTemplateEditor(editor.func, template)
|
||||
} as Editor)
|
||||
) || [])
|
||||
...(extraEditors?.map((editor) => {
|
||||
return {
|
||||
key: identifierString(
|
||||
`${editor.options.plugin_name}-${editor.options.key}`
|
||||
),
|
||||
name: editor.options.title,
|
||||
icon: GetIcon(editor.options.icon || 'plugin'),
|
||||
component: getPluginTemplateEditor(editor.func, template)
|
||||
} as Editor;
|
||||
}) || [])
|
||||
);
|
||||
|
||||
return editors;
|
||||
@ -135,7 +142,7 @@ export function TemplateDrawer({
|
||||
({
|
||||
key: preview.options.key,
|
||||
name: preview.options.title,
|
||||
icon: GetIcon(preview.options.icon),
|
||||
icon: GetIcon(preview.options.icon || 'plugin'),
|
||||
component: getPluginTemplatePreview(preview.func, template)
|
||||
} as PreviewArea)
|
||||
) || [])
|
||||
|
@ -140,7 +140,7 @@ export function UserDrawer({
|
||||
<Text ml={'md'}>
|
||||
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
|
||||
<List>
|
||||
{userDetail?.groups?.map((group) => (
|
||||
{userDetail?.groups?.map((group: any) => (
|
||||
<List.Item key={group.pk}>
|
||||
<DetailDrawerLink
|
||||
to={`../group-${group.pk}`}
|
||||
|
@ -33,9 +33,8 @@ export const doQuickLogin = async (
|
||||
|
||||
await page.goto(`${url}/login/?login=${username}&password=${password}`);
|
||||
await page.waitForURL('**/platform/home');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
||||
.waitFor();
|
||||
|
||||
await page.getByText(/InvenTree Demo Server/).waitFor();
|
||||
};
|
||||
|
||||
export const doLogout = async (page) => {
|
||||
|
@ -52,12 +52,12 @@ test('Modals as admin', async ({ page }) => {
|
||||
|
||||
await page.goto('./platform/');
|
||||
|
||||
// qr code modal
|
||||
await page.getByRole('button', { name: 'Open QR code scanner' }).click();
|
||||
// Barcode scanning window
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
await page.getByRole('button', { name: 'Open QR code scanner' }).click();
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.getByRole('button', { name: 'Close modal' }).click();
|
||||
await page.getByRole('button', { name: 'Open QR code scanner' }).click();
|
||||
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
});
|
||||
|
64
src/frontend/tests/pages/pui_dashboard.spec.ts
Normal file
64
src/frontend/tests/pages/pui_dashboard.spec.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { doQuickLogin } from '../login.js';
|
||||
import { setPluginState } from '../settings.js';
|
||||
|
||||
test('Pages - Dashboard - Basic', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByText('Use the menu to add widgets').waitFor();
|
||||
|
||||
// Let's add some widgets
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
|
||||
await page.getByLabel('dashboard-widgets-filter-input').fill('overdue order');
|
||||
|
||||
await page.getByLabel('add-widget-ovr-so').click();
|
||||
await page.getByLabel('add-widget-ovr-po').click();
|
||||
|
||||
await page.getByLabel('dashboard-widgets-filter-clear').click();
|
||||
|
||||
// Close the widget
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that the widgets are visible
|
||||
await page.getByText('Overdue Sales Orders').waitFor();
|
||||
await page.getByText('Overdue Purchase Orders').waitFor();
|
||||
|
||||
// Let's remove one of the widgets
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Remove Widgets' }).click();
|
||||
await page.getByLabel('remove-dashboard-item-ovr-so').click();
|
||||
|
||||
// Accept the layout
|
||||
await page.getByLabel('dashboard-accept-layout').click();
|
||||
});
|
||||
|
||||
test('Pages - Dashboard - Plugins', async ({ page, request }) => {
|
||||
// Ensure that the "SampleUI" plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sampleui',
|
||||
state: true
|
||||
});
|
||||
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Add a dashboard widget from the SampleUI plugin
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
|
||||
await page.getByLabel('dashboard-widgets-filter-input').fill('sample');
|
||||
|
||||
// Add the widget
|
||||
await page.getByLabel(/add-widget-p-sampleui-sample-/).click();
|
||||
|
||||
// Close the widget
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that the widget is visible
|
||||
await page.getByRole('heading', { name: 'Sample Dashboard Item' }).waitFor();
|
||||
await page.getByText('Hello world! This is a sample').waitFor();
|
||||
});
|
@ -1,13 +0,0 @@
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { doQuickLogin } from '../login.js';
|
||||
|
||||
test('Pages - Index - Dashboard', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Dashboard auto update
|
||||
await page.getByRole('tab', { name: 'Dashboard' }).click();
|
||||
await page.getByText('Autoupdate').click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByText('Autoupdate').click();
|
||||
await page.getByText('This page is a replacement').waitFor();
|
||||
});
|
@ -2,7 +2,76 @@ import { test } from '../baseFixtures';
|
||||
import { baseUrl } from '../defaults';
|
||||
import { doQuickLogin } from '../login';
|
||||
|
||||
test('Pages - Part - Locking', async ({ page }) => {
|
||||
/**
|
||||
* CHeck each panel tab for the "Parts" page
|
||||
*/
|
||||
test('Parts - Tabs', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/home`);
|
||||
await page.getByRole('tab', { name: 'Parts' }).click();
|
||||
|
||||
await page.waitForURL('**/platform/part/category/index/details');
|
||||
await page.goto(`${baseUrl}/part/category/index/parts`);
|
||||
await page.getByText('1551ABK').click();
|
||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||
await page.getByRole('tab', { name: 'Used In' }).click();
|
||||
await page.getByRole('tab', { name: 'Pricing' }).click();
|
||||
await page.getByRole('tab', { name: 'Manufacturers' }).click();
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Scheduling' }).click();
|
||||
await page.getByRole('tab', { name: 'Stock History' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Related Parts' }).click();
|
||||
|
||||
// Related Parts
|
||||
await page.getByText('1551ACLR').click();
|
||||
await page.getByRole('tab', { name: 'Part Details' }).click();
|
||||
await page.getByRole('tab', { name: 'Parameters' }).click();
|
||||
await page
|
||||
.getByRole('tab', { name: 'Part Details' })
|
||||
.locator('xpath=..')
|
||||
.getByRole('tab', { name: 'Stock', exact: true })
|
||||
.click();
|
||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||
await page.getByRole('tab', { name: 'Used In' }).click();
|
||||
await page.getByRole('tab', { name: 'Pricing' }).click();
|
||||
|
||||
await page.goto(`${baseUrl}/part/category/index/parts`);
|
||||
await page.getByText('Blue Chair').click();
|
||||
await page.getByRole('tab', { name: 'Bill of Materials' }).click();
|
||||
await page.getByRole('tab', { name: 'Build Orders' }).click();
|
||||
});
|
||||
|
||||
test('Parts - Manufacturer Parts', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/84/manufacturers`);
|
||||
|
||||
await page.getByRole('tab', { name: 'Manufacturers' }).click();
|
||||
await page.getByText('Hammond Manufacturing').click();
|
||||
await page.getByRole('tab', { name: 'Parameters' }).click();
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByText('1551ACLR - 1551ACLR').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Supplier Parts', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/15/suppliers`);
|
||||
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click();
|
||||
await page.getByRole('tab', { name: 'Received Stock' }).click(); //
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Pricing' }).click();
|
||||
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Locking', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Navigate to a known assembly which is *not* locked
|
||||
@ -28,7 +97,7 @@ test('Pages - Part - Locking', async ({ page }) => {
|
||||
await page.getByText('Part parameters cannot be').waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Part - Allocations', async ({ page }) => {
|
||||
test('Parts - Allocations', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Let's look at the allocations for a single stock item
|
||||
@ -57,7 +126,7 @@ test('Pages - Part - Allocations', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||
test('Parts - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Part with no history
|
||||
@ -106,7 +175,7 @@ test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||
await page.waitForURL('**/part/98/**');
|
||||
});
|
||||
|
||||
test('Pages - Part - Pricing (Supplier)', async ({ page }) => {
|
||||
test('Parts - Pricing (Supplier)', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Part
|
||||
@ -132,7 +201,7 @@ test('Pages - Part - Pricing (Supplier)', async ({ page }) => {
|
||||
// await page.waitForURL('**/purchasing/supplier-part/697/');
|
||||
});
|
||||
|
||||
test('Pages - Part - Pricing (Variant)', async ({ page }) => {
|
||||
test('Parts - Pricing (Variant)', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Part
|
||||
@ -158,7 +227,7 @@ test('Pages - Part - Pricing (Variant)', async ({ page }) => {
|
||||
await page.waitForURL('**/part/109/**');
|
||||
});
|
||||
|
||||
test('Pages - Part - Pricing (Internal)', async ({ page }) => {
|
||||
test('Parts - Pricing (Internal)', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Part
|
||||
@ -183,7 +252,7 @@ test('Pages - Part - Pricing (Internal)', async ({ page }) => {
|
||||
await page.getByText('Part *M2x4 SHCSSocket head').click();
|
||||
});
|
||||
|
||||
test('Pages - Part - Pricing (Purchase)', async ({ page }) => {
|
||||
test('Parts - Pricing (Purchase)', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Part
|
||||
@ -205,7 +274,7 @@ test('Pages - Part - Pricing (Purchase)', async ({ page }) => {
|
||||
await page.getByText('2022-04-29').waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Part - Attachments', async ({ page }) => {
|
||||
test('Parts - Attachments', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/69/attachments`);
|
||||
@ -227,7 +296,7 @@ test('Pages - Part - Attachments', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Pages - Part - Parameters', async ({ page }) => {
|
||||
test('Parts - Parameters', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/69/parameters`);
|
||||
@ -254,7 +323,7 @@ test('Pages - Part - Parameters', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Pages - Part - Notes', async ({ page }) => {
|
||||
test('Parts - Notes', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/69/notes`);
|
||||
@ -276,7 +345,7 @@ test('Pages - Part - Notes', async ({ page }) => {
|
||||
await page.getByLabel('Close Editor').waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Part - 404', async ({ page }) => {
|
||||
test('Parts - 404', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/99999/`);
|
||||
@ -286,7 +355,7 @@ test('Pages - Part - 404', async ({ page }) => {
|
||||
await page.evaluate(() => console.clear());
|
||||
});
|
||||
|
||||
test('Pages - Part - Revision', async ({ page }) => {
|
||||
test('Parts - Revision', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/906/details`);
|
||||
|
@ -14,9 +14,7 @@ test('Basic Login Test', async ({ page }) => {
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page
|
||||
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
|
||||
.click();
|
||||
await page.getByText('InvenTree Demo Server').waitFor();
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
@ -47,9 +45,7 @@ test('Quick Login Test', async ({ page }) => {
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page
|
||||
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
|
||||
.click();
|
||||
await page.getByText('InvenTree Demo Server').waitFor();
|
||||
|
||||
// Logout (via URL)
|
||||
await page.goto(`${baseUrl}/logout/`);
|
||||
|
@ -1,34 +1,16 @@
|
||||
import { systemKey, test } from './baseFixtures.js';
|
||||
import { baseUrl } from './defaults.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
test('Quick Command', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Open Spotlight with Keyboard Shortcut
|
||||
await page.locator('body').press(`${systemKey}+k`);
|
||||
await page.waitForTimeout(200);
|
||||
await page
|
||||
.getByRole('button', { name: 'Go to the InvenTree dashboard' })
|
||||
.click();
|
||||
await page.locator('p').filter({ hasText: 'Dashboard' }).waitFor();
|
||||
await page.waitForURL('**/platform/dashboard');
|
||||
|
||||
// Open Spotlight with Button
|
||||
await page.getByLabel('open-spotlight').click();
|
||||
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
||||
.click();
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
// Open Spotlight with Keyboard Shortcut and Search
|
||||
await page.locator('body').press(`${systemKey}+k`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByPlaceholder('Search...').fill('Dashboard');
|
||||
await page.getByPlaceholder('Search...').press('Tab');
|
||||
await page.getByPlaceholder('Search...').press('Enter');
|
||||
await page.waitForURL('**/platform/dashboard');
|
||||
await page.waitForURL('**/platform/home');
|
||||
});
|
||||
|
||||
test('Quick Command - No Keys', async ({ page }) => {
|
||||
@ -36,23 +18,31 @@ test('Quick Command - No Keys', async ({ page }) => {
|
||||
|
||||
// Open Spotlight with Button
|
||||
await page.getByLabel('open-spotlight').click();
|
||||
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
|
||||
.getByRole('button', { name: 'Dashboard Go to the InvenTree' })
|
||||
.click();
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page.getByText('InvenTree Demo Server').waitFor();
|
||||
await page.waitForURL('**/platform/home');
|
||||
|
||||
// Use navigation menu
|
||||
await page.getByLabel('open-spotlight').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open Navigation Open the main' })
|
||||
.click();
|
||||
// assert the nav headers are visible
|
||||
await page.getByRole('heading', { name: 'Navigation' }).waitFor();
|
||||
await page.getByRole('heading', { name: 'Pages' }).waitFor();
|
||||
await page.getByRole('heading', { name: 'Documentation' }).waitFor();
|
||||
await page.getByRole('heading', { name: 'About' }).waitFor();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// assert the nav headers are visible
|
||||
await page.getByText('Navigation').waitFor();
|
||||
await page.getByText('Documentation').waitFor();
|
||||
await page.getByText('About').first().waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'Notifications', exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Dashboard', exact: true }).waitFor();
|
||||
|
||||
// close the nav
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// use server info
|
||||
@ -65,7 +55,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
||||
await page.getByRole('cell', { name: 'Instance Name' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
|
||||
await page.waitForURL('**/platform');
|
||||
await page.waitForURL('**/platform/home');
|
||||
|
||||
// use license info
|
||||
await page.getByLabel('open-spotlight').click();
|
||||
|
@ -2,72 +2,6 @@ import { test } from './baseFixtures.js';
|
||||
import { baseUrl } from './defaults.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
test('Parts', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/home`);
|
||||
await page.getByRole('tab', { name: 'Parts' }).click();
|
||||
|
||||
await page.waitForURL('**/platform/part/category/index/details');
|
||||
await page.goto(`${baseUrl}/part/category/index/parts`);
|
||||
await page.getByText('1551ABK').click();
|
||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||
await page.getByRole('tab', { name: 'Used In' }).click();
|
||||
await page.getByRole('tab', { name: 'Pricing' }).click();
|
||||
await page.getByRole('tab', { name: 'Manufacturers' }).click();
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Scheduling' }).click();
|
||||
await page.getByRole('tab', { name: 'Stock History' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Related Parts' }).click();
|
||||
|
||||
// Related Parts
|
||||
await page.getByText('1551ACLR').click();
|
||||
await page.getByRole('tab', { name: 'Part Details' }).click();
|
||||
await page.getByRole('tab', { name: 'Parameters' }).click();
|
||||
await page
|
||||
.getByRole('tab', { name: 'Part Details' })
|
||||
.locator('xpath=..')
|
||||
.getByRole('tab', { name: 'Stock', exact: true })
|
||||
.click();
|
||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||
await page.getByRole('tab', { name: 'Used In' }).click();
|
||||
await page.getByRole('tab', { name: 'Pricing' }).click();
|
||||
|
||||
await page.goto(`${baseUrl}/part/category/index/parts`);
|
||||
await page.getByText('Blue Chair').click();
|
||||
await page.getByRole('tab', { name: 'Bill of Materials' }).click();
|
||||
await page.getByRole('tab', { name: 'Build Orders' }).click();
|
||||
});
|
||||
|
||||
test('Parts - Manufacturer Parts', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/84/manufacturers`);
|
||||
|
||||
await page.getByRole('tab', { name: 'Manufacturers' }).click();
|
||||
await page.getByText('Hammond Manufacturing').click();
|
||||
await page.getByRole('tab', { name: 'Parameters' }).click();
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByText('1551ACLR - 1551ACLR').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Supplier Parts', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/15/suppliers`);
|
||||
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click();
|
||||
await page.getByRole('tab', { name: 'Received Stock' }).click(); //
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Pricing' }).click();
|
||||
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
|
||||
});
|
||||
|
||||
test('Sales', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
@ -122,13 +56,13 @@ test('Sales', async ({ page }) => {
|
||||
test('Scanning', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByLabel('Homenav').click();
|
||||
await page.getByLabel('navigation-menu').click();
|
||||
await page.getByRole('button', { name: 'System Information' }).click();
|
||||
await page.locator('button').filter({ hasText: 'Dismiss' }).click();
|
||||
await page.getByRole('link', { name: 'Scanning' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page.locator('.mantine-Overlay-root').click();
|
||||
await page.getByLabel('navigation-menu').click();
|
||||
await page.getByRole('button', { name: 'Scan Barcode' }).click();
|
||||
|
||||
await page.getByPlaceholder('Select input method').click();
|
||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
||||
await page.getByPlaceholder('Enter item serial or data').click();
|
||||
@ -140,40 +74,6 @@ test('Scanning', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Manual input' }).click();
|
||||
});
|
||||
|
||||
test('Language / Color', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Ally Access' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Logout' }).click();
|
||||
await page.getByRole('button', { name: 'Send me an email' }).click();
|
||||
await page.getByRole('button').nth(3).click();
|
||||
await page.getByLabel('Select language').first().click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page.getByRole('button', { name: 'Benutzername und Passwort' }).click();
|
||||
await page.getByPlaceholder('Ihr Benutzername').click();
|
||||
await page.getByPlaceholder('Ihr Benutzername').fill('admin');
|
||||
await page.getByPlaceholder('Ihr Benutzername').press('Tab');
|
||||
await page.getByPlaceholder('Dein Passwort').fill('inventree');
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page
|
||||
.locator('span')
|
||||
.filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page
|
||||
.locator('span')
|
||||
.filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('button', { name: "InvenTree's Logo" }).first().click();
|
||||
await page.getByRole('tab', { name: 'Dashboard' }).click();
|
||||
await page.waitForURL('**/platform/dashboard');
|
||||
});
|
||||
|
||||
test('Company', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
|
@ -14,6 +14,8 @@ test('Plugins - Panels', async ({ page, request }) => {
|
||||
value: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Ensure that the SampleUI plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
@ -21,28 +23,34 @@ test('Plugins - Panels', async ({ page, request }) => {
|
||||
state: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to the "part" page
|
||||
await page.goto(`${baseUrl}/part/69/`);
|
||||
|
||||
// Ensure basic part tab is available
|
||||
await page.getByRole('tab', { name: 'Part Details' }).waitFor();
|
||||
|
||||
// Allow time for the plugin panels to load (they are loaded asynchronously)
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check out each of the plugin panels
|
||||
await page.getByRole('tab', { name: 'Sample Panel' }).click();
|
||||
await page
|
||||
.getByText('This is a sample panel which appears on every page')
|
||||
.waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Broken Panel' }).click();
|
||||
await page.getByText('Error Loading Plugin').waitFor();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByRole('tab', { name: 'Dynamic Part Panel' }).click();
|
||||
await page.getByText('Error occurred while loading plugin content').waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Dynamic Panel' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByText('Instance ID: 69');
|
||||
await page
|
||||
.getByText('This panel has been dynamically rendered by the plugin system')
|
||||
.waitFor();
|
||||
await page.getByText('Instance ID: 69');
|
||||
|
||||
await page.getByRole('tab', { name: 'Part Panel', exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByText('This content has been rendered by a custom plugin');
|
||||
|
||||
// Disable the plugin, and ensure it is no longer visible
|
||||
|
@ -3,7 +3,45 @@ import { apiUrl, baseUrl } from './defaults.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
import { setSettingState } from './settings.js';
|
||||
|
||||
test('Admin', async ({ page }) => {
|
||||
/**
|
||||
* Adjust language and color settings
|
||||
*/
|
||||
test('Settings - Language / Color', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Ally Access' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Logout' }).click();
|
||||
await page.getByRole('button', { name: 'Send me an email' }).click();
|
||||
await page.getByRole('button').nth(3).click();
|
||||
await page.getByLabel('Select language').first().click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page.getByRole('button', { name: 'Benutzername und Passwort' }).click();
|
||||
await page.getByPlaceholder('Ihr Benutzername').click();
|
||||
await page.getByPlaceholder('Ihr Benutzername').fill('admin');
|
||||
await page.getByPlaceholder('Ihr Benutzername').press('Tab');
|
||||
await page.getByPlaceholder('Dein Passwort').fill('inventree');
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Note: changes to the dashboard have invalidated these tests (for now)
|
||||
// await page
|
||||
// .locator('span')
|
||||
// .filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
|
||||
// .getByRole('button')
|
||||
// .click();
|
||||
// await page
|
||||
// .locator('span')
|
||||
// .filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
|
||||
// .getByRole('button')
|
||||
// .click();
|
||||
|
||||
await page.getByRole('tab', { name: 'Dashboard' }).click();
|
||||
await page.waitForURL('**/platform/home');
|
||||
});
|
||||
|
||||
test('Settings - Admin', async ({ page }) => {
|
||||
// Note here we login with admin access
|
||||
await doQuickLogin(page, 'admin', 'inventree');
|
||||
|
||||
@ -86,7 +124,7 @@ test('Admin', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
});
|
||||
|
||||
test('Admin - Barcode History', async ({ page, request }) => {
|
||||
test('Settings - Admin - Barcode History', async ({ page, request }) => {
|
||||
// Login with admin credentials
|
||||
await doQuickLogin(page, 'admin', 'inventree');
|
||||
|
||||
@ -123,7 +161,7 @@ test('Admin - Barcode History', async ({ page, request }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Admin - Unauthorized', async ({ page }) => {
|
||||
test('Settings - Admin - Unauthorized', async ({ page }) => {
|
||||
// Try to access "admin" page with a non-staff user
|
||||
await doQuickLogin(page, 'allaccess', 'nolimits');
|
||||
|
||||
|
Reference in New Issue
Block a user