mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 20:15:44 +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>";
|
||||
}
|
Reference in New Issue
Block a user