2
0
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:
Oliver
2024-11-02 08:48:29 +11:00
committed by GitHub
parent c4031dba7f
commit 18e5b0df58
85 changed files with 3125 additions and 2126 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
),
)
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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