diff --git a/docs/docs/extend/plugins/ui.md b/docs/docs/extend/plugins/ui.md index 160368784265..e38482be27b7 100644 --- a/docs/docs/extend/plugins/ui.md +++ b/docs/docs/extend/plugins/ui.md @@ -20,7 +20,7 @@ When rendering certain content in the user interface, the rendering functions ar Many of the pages in the InvenTree web interface are built using a series of "panels" which are displayed on the page. Custom panels can be added to these pages, by implementing the `get_ui_panels` method: -::: plugin.base.integration.UserInterfaceMixin.UserInterfaceMixin.get_ui_panels +::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_panels options: show_bases: False show_root_heading: False @@ -89,6 +89,46 @@ export function isPanelHidden(context) { } ``` +## Custom UI Functions + +User interface plugins can also provide additional user interface functions. These functions can be provided via the `get_ui_features` method: + +::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_features + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + show_sources: True + summary: False + members: [] + +::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin.get_ui_features + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + show_source: True + members: [] + + +Currently the following functions can be extended: + +### Template editors + +The `template_editor` feature type can be used to provide custom template editors. + +**Example:** + +{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }} + +### Template previews + +The `template_preview` feature type can be used to provide custom template previews. For an example see: + +**Example:** + +{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }} + ## Sample Plugin A sample plugin which implements custom user interface functionality is provided in the InvenTree source code: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 868672fababb..43f12f7737a6 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 258 +INVENTREE_API_VERSION = 259 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v259 - 2024-09-20 : https://github.com/inventree/InvenTree/pull/8137 + - Implements new API endpoint for enabling custom UI features via plugins + v258 - 2024-09-24 : https://github.com/inventree/InvenTree/pull/8163 - Enhances the existing PartScheduling API endpoint - Adds a formal DRF serializer to the endpoint diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index 4b2172929b8c..ff1e301357c8 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -11,15 +11,12 @@ from drf_spectacular.utils import extend_schema from rest_framework import permissions, status from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView import plugin.serializers as PluginSerializers from common.api import GlobalSettingsPermissions -from common.settings import get_global_setting from InvenTree.api import MetadataView -from InvenTree.exceptions import log_error from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ( CreateAPI, @@ -33,6 +30,7 @@ from plugin.base.action.api import ActionPluginView from plugin.base.barcodes.api import barcode_api_urls from plugin.base.locate.api import LocatePluginView +from plugin.base.ui.api import ui_plugins_api_urls from plugin.models import PluginConfig, PluginSetting from plugin.plugin import InvenTreePlugin @@ -417,43 +415,6 @@ def get(self, request): return Response(result) -class PluginPanelList(APIView): - """API endpoint for listing all available plugin panels.""" - - permission_classes = [IsAuthenticated] - serializer_class = PluginSerializers.PluginPanelSerializer - - @extend_schema(responses={200: PluginSerializers.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(PluginSerializers.PluginPanelSerializer(panels, many=True).data) - - class PluginMetadataView(MetadataView): """Metadata API endpoint for the PluginConfig model.""" @@ -468,21 +429,8 @@ class PluginMetadataView(MetadataView): path( 'plugins/', include([ - path( - 'ui/', - include([ - path( - 'panels/', - include([ - path( - '', - PluginPanelList.as_view(), - name='api-plugin-panel-list', - ) - ]), - ) - ]), - ), + # UI plugins + path('ui/', include(ui_plugins_api_urls)), # Plugin management path('reload/', PluginReload.as_view(), name='api-plugin-reload'), path('install/', PluginInstall.as_view(), name='api-plugin-install'), diff --git a/src/backend/InvenTree/plugin/base/integration/test_mixins.py b/src/backend/InvenTree/plugin/base/integration/test_mixins.py index 83caccad30e9..c5cf6aa195b2 100644 --- a/src/backend/InvenTree/plugin/base/integration/test_mixins.py +++ b/src/backend/InvenTree/plugin/base/integration/test_mixins.py @@ -8,8 +8,7 @@ from error_report.models import Error -from common.models import InvenTreeSetting -from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase +from InvenTree.unit_test import InvenTreeTestCase from plugin import InvenTreePlugin from plugin.base.integration.PanelMixin import PanelMixin from plugin.helpers import MixinNotImplementedError @@ -479,100 +478,3 @@ class Wrong(PanelMixin, InvenTreePlugin): plugin = Wrong() plugin.get_custom_panels('abc', 'abc') - - -class UserInterfaceMixinTests(InvenTreeAPITestCase): - """Test the UserInterfaceMixin plugin mixin class.""" - - roles = 'all' - - fixtures = ['part', 'category', 'location', 'stock'] - - @classmethod - def setUpTestData(cls): - """Set up the test case.""" - super().setUpTestData() - - # Ensure that the 'sampleui' plugin is installed and active - registry.set_plugin_state('sampleui', True) - - # Ensure that UI plugins are enabled - InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None) - - def test_installed(self): - """Test that the sample UI plugin is installed and active.""" - plugin = registry.get_plugin('sampleui') - self.assertTrue(plugin.is_active()) - - plugins = registry.with_mixin('ui') - self.assertGreater(len(plugins), 0) - - def test_panels(self): - """Test that the sample UI plugin provides custom panels.""" - from part.models import Part - - plugin = registry.get_plugin('sampleui') - - _part = Part.objects.first() - - # Ensure that the part is active - _part.active = True - _part.save() - - url = reverse('api-plugin-panel-list') - - query_data = {'target_model': 'part', 'target_id': _part.pk} - - # Enable *all* plugin settings - plugin.set_setting('ENABLE_PART_PANELS', True) - plugin.set_setting('ENABLE_PURCHASE_ORDER_PANELS', True) - plugin.set_setting('ENABLE_BROKEN_PANELS', True) - plugin.set_setting('ENABLE_DYNAMIC_PANEL', True) - - # Request custom panel information for a part instance - response = self.get(url, data=query_data) - - # There should be 4 active panels for the part by default - self.assertEqual(4, len(response.data)) - - _part.active = False - _part.save() - - response = self.get(url, data=query_data) - - # As the part is not active, only 3 panels left - self.assertEqual(3, len(response.data)) - - # Disable the "ENABLE_PART_PANELS" setting, and try again - plugin.set_setting('ENABLE_PART_PANELS', False) - - response = self.get(url, data=query_data) - - # There should still be 3 panels - self.assertEqual(3, 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]) - - 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[2]['name'], 'dynamic_panel') - self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js') - self.assertNotIn('content', response.data[2]) - - # Next, disable the global setting for UI integration - InvenTreeSetting.set_setting( - 'ENABLE_PLUGINS_INTERFACE', False, change_user=None - ) - - response = self.get(url, data=query_data) - - # There should be no panels available - self.assertEqual(0, len(response.data)) - - # Set the setting back to True for subsequent tests - InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None) diff --git a/src/backend/InvenTree/plugin/base/ui/__init__.py b/src/backend/InvenTree/plugin/base/ui/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/InvenTree/plugin/base/ui/api.py b/src/backend/InvenTree/plugin/base/ui/api.py new file mode 100644 index 000000000000..ad85010a292e --- /dev/null +++ b/src/backend/InvenTree/plugin/base/ui/api.py @@ -0,0 +1,94 @@ +"""API for UI plugins.""" + +from django.urls import path + +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +import plugin.base.ui.serializers as UIPluginSerializers +from common.settings import get_global_setting +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.""" + + permission_classes = [IsAuthenticated] + serializer_class = UIPluginSerializers.PluginUIFeatureSerializer + + @extend_schema( + responses={200: UIPluginSerializers.PluginUIFeatureSerializer(many=True)} + ) + def get(self, request, feature): + """Show available plugin ui features.""" + features = [] + + if get_global_setting('ENABLE_PLUGINS_INTERFACE'): + # 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 + ) + + if plugin_features and type(plugin_features) is list: + for _feature in plugin_features: + features.append(_feature) + + return Response( + UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data + ) + + +ui_plugins_api_urls = [ + path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'), + path( + 'features//', + PluginUIFeatureList.as_view(), + name='api-plugin-ui-feature-list', + ), +] diff --git a/src/backend/InvenTree/plugin/base/integration/UserInterfaceMixin.py b/src/backend/InvenTree/plugin/base/ui/mixins.py similarity index 69% rename from src/backend/InvenTree/plugin/base/integration/UserInterfaceMixin.py rename to src/backend/InvenTree/plugin/base/ui/mixins.py index 7c70dd2c4e53..b8e5a0fecbef 100644 --- a/src/backend/InvenTree/plugin/base/integration/UserInterfaceMixin.py +++ b/src/backend/InvenTree/plugin/base/ui/mixins.py @@ -4,7 +4,7 @@ """ import logging -from typing import TypedDict +from typing import Literal, TypedDict from rest_framework.request import Request @@ -29,6 +29,23 @@ class CustomPanel(TypedDict): source: str +FeatureType = Literal['template_editor', 'template_preview'] + + +class UIFeature(TypedDict): + """Base type definition for a ui feature. + + Attributes: + 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). + """ + + feature_type: FeatureType + options: dict + source: str + + class UserInterfaceMixin: """Plugin mixin class which handles injection of custom elements into the front-end interface. @@ -48,7 +65,7 @@ class MixinMeta: def __init__(self): """Register mixin.""" super().__init__() - self.add_mixin('ui', True, __class__) + self.add_mixin('ui', True, __class__) # type: ignore def get_ui_panels( self, instance_type: str, instance_id: int, request: Request, **kwargs @@ -78,3 +95,19 @@ def get_ui_panels( """ # Default implementation returns an empty list return [] + + def get_ui_features( + self, feature_type: FeatureType, context: dict, request: Request + ) -> 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 + request: HTTPRequest object (including user information) + + Returns: + list: A list of custom UIFeature dicts to be injected into the UI + """ + # Default implementation returns an empty list + return [] diff --git a/src/backend/InvenTree/plugin/base/ui/serializers.py b/src/backend/InvenTree/plugin/base/ui/serializers.py new file mode 100644 index 000000000000..18c69bb1af1b --- /dev/null +++ b/src/backend/InvenTree/plugin/base/ui/serializers.py @@ -0,0 +1,68 @@ +"""Serializers for UI plugin api.""" + +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', + '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 + ) + + 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'] + + # Required fields + feature_type = serializers.CharField( + label=_('Feature Type'), required=True, allow_blank=False + ) + + options = serializers.DictField(label=_('Feature Options'), required=True) + + source = serializers.CharField( + label=_('Feature Source (javascript)'), required=True, allow_blank=False + ) diff --git a/src/backend/InvenTree/plugin/base/ui/tests.py b/src/backend/InvenTree/plugin/base/ui/tests.py new file mode 100644 index 000000000000..faac8ba116cf --- /dev/null +++ b/src/backend/InvenTree/plugin/base/ui/tests.py @@ -0,0 +1,157 @@ +"""Unit tests for base mixins for plugins.""" + +from django.urls import reverse + +from common.models import InvenTreeSetting +from InvenTree.unit_test import InvenTreeAPITestCase +from plugin.registry import registry + + +class UserInterfaceMixinTests(InvenTreeAPITestCase): + """Test the UserInterfaceMixin plugin mixin class.""" + + roles = 'all' + + fixtures = ['part', 'category', 'location', 'stock'] + + @classmethod + def setUpTestData(cls): + """Set up the test case.""" + super().setUpTestData() + + # Ensure that the 'sampleui' plugin is installed and active + registry.set_plugin_state('sampleui', True) + + # Ensure that UI plugins are enabled + InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None) + + def test_installed(self): + """Test that the sample UI plugin is installed and active.""" + plugin = registry.get_plugin('sampleui') + self.assertTrue(plugin.is_active()) + + plugins = registry.with_mixin('ui') + self.assertGreater(len(plugins), 0) + + def test_panels(self): + """Test that the sample UI plugin provides custom panels.""" + from part.models import Part + + plugin = registry.get_plugin('sampleui') + + _part = Part.objects.first() + + # Ensure that the part is active + _part.active = True + _part.save() + + url = reverse('api-plugin-panel-list') + + query_data = {'target_model': 'part', 'target_id': _part.pk} + + # Enable *all* plugin settings + plugin.set_setting('ENABLE_PART_PANELS', True) + plugin.set_setting('ENABLE_PURCHASE_ORDER_PANELS', True) + plugin.set_setting('ENABLE_BROKEN_PANELS', True) + plugin.set_setting('ENABLE_DYNAMIC_PANEL', True) + + # Request custom panel information for a part instance + response = self.get(url, data=query_data) + + # There should be 4 active panels for the part by default + self.assertEqual(4, len(response.data)) + + _part.active = False + _part.save() + + response = self.get(url, data=query_data) + + # As the part is not active, only 3 panels left + self.assertEqual(3, len(response.data)) + + # Disable the "ENABLE_PART_PANELS" setting, and try again + plugin.set_setting('ENABLE_PART_PANELS', False) + + response = self.get(url, data=query_data) + + # There should still be 3 panels + self.assertEqual(3, 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]) + + 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[2]['name'], 'dynamic_panel') + self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js') + self.assertNotIn('content', response.data[2]) + + # Next, disable the global setting for UI integration + InvenTreeSetting.set_setting( + 'ENABLE_PLUGINS_INTERFACE', False, change_user=None + ) + + response = self.get(url, data=query_data) + + # There should be no panels available + self.assertEqual(0, len(response.data)) + + # 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.""" + template_editor_url = reverse( + 'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'} + ) + template_preview_url = reverse( + 'api-plugin-ui-feature-list', kwargs={'feature': 'template_preview'} + ) + + query_data_label = {'template_type': 'labeltemplate', 'template_model': 'part'} + query_data_report = { + 'template_type': 'reporttemplate', + 'template_model': 'part', + } + + # Request custom template editor information + response = self.get(template_editor_url, data=query_data_label) + self.assertEqual(1, len(response.data)) + + response = self.get(template_editor_url, data=query_data_report) + self.assertEqual(0, len(response.data)) + + 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', + ) + + # Next, disable the global setting for UI integration + InvenTreeSetting.set_setting( + 'ENABLE_PLUGINS_INTERFACE', False, change_user=None + ) + + response = self.get(template_editor_url, data=query_data_label) + + # There should be no features available + self.assertEqual(0, len(response.data)) + + # Set the setting back to True for subsequent tests + InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None) diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index 1de17d613d57..bd5851f667ac 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -14,10 +14,10 @@ from plugin.base.integration.ScheduleMixin import ScheduleMixin from plugin.base.integration.SettingsMixin import SettingsMixin from plugin.base.integration.UrlsMixin import UrlsMixin -from plugin.base.integration.UserInterfaceMixin import UserInterfaceMixin from plugin.base.integration.ValidationMixin import ValidationMixin from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.locate.mixins import LocateMixin +from plugin.base.ui.mixins import UserInterfaceMixin __all__ = [ 'APICallMixin', diff --git a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py index deaa655e2456..bbf356a5d751 100644 --- a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py @@ -122,3 +122,36 @@ def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs) }) 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', + } + ] + + if feature_type == 'template_preview': + return [ + { + 'feature_type': 'template_preview', + 'options': { + 'key': 'sample-template-preview', + 'title': 'Sample Template Preview', + 'icon': 'category', + }, + 'source': '/static/plugin/sample_template.js:getTemplatePreview', + } + ] + + return [] diff --git a/src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js b/src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js new file mode 100644 index 000000000000..333f6d295486 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js @@ -0,0 +1,33 @@ +export function getTemplateEditor({ featureContext, pluginContext }) { + const { ref } = featureContext; + console.log("Template editor feature was called with", featureContext, pluginContext); + const t = document.createElement("textarea"); + t.id = 'sample-template-editor-textarea'; + t.rows = 25; + t.cols = 60; + + featureContext.registerHandlers({ + setCode: (code) => { + t.value = code; + }, + getCode: () => { + return t.value; + } + }); + + 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 = "

Hello world

"; +} diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index 787c9ad2b431..eec4cd0420c7 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -301,46 +301,3 @@ def to_internal_value(self, data): def to_representation(self, value): """Return the 'key' of the PluginConfig object.""" return value.key - - -class PluginPanelSerializer(serializers.Serializer): - """Serializer for a plugin panel.""" - - class Meta: - """Meta for serializer.""" - - fields = [ - 'plugin', - 'name', - 'label', - # Following fields are optional - 'icon', - 'content', - '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 - ) - - source = serializers.CharField( - label=_('Panel Source (javascript)'), required=False, allow_blank=True - ) diff --git a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx index 1f03f3ce71a8..40fdc0ee84b0 100644 --- a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx +++ b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx @@ -52,7 +52,7 @@ export type Editor = { }; type PreviewAreaProps = {}; -type PreviewAreaRef = { +export type PreviewAreaRef = { updatePreview: ( code: string, previewItem: string, @@ -300,6 +300,7 @@ export function TemplateEditor(props: Readonly) { { + const host = useLocalState((s) => s.host); + const navigate = useNavigate(); + const user = useUserState(); + const { colorScheme } = useMantineColorScheme(); + const theme = useMantineTheme(); + const globalSettings = useGlobalSettingsState(); + const userSettings = useUserSettingsState(); + + const contextData = useMemo(() => { + return { + user: user, + host: host, + api: api, + navigate: navigate, + globalSettings: globalSettings, + userSettings: userSettings, + theme: theme, + colorScheme: colorScheme + }; + }, [ + user, + host, + api, + navigate, + globalSettings, + userSettings, + theme, + colorScheme + ]); + + return contextData; +}; diff --git a/src/frontend/src/components/plugins/PluginPanel.tsx b/src/frontend/src/components/plugins/PluginPanel.tsx index dbabc549b8a4..153a4cd1ab5e 100644 --- a/src/frontend/src/components/plugins/PluginPanel.tsx +++ b/src/frontend/src/components/plugins/PluginPanel.tsx @@ -3,7 +3,7 @@ import { Alert, Stack, Text } from '@mantine/core'; import { IconExclamationCircle } from '@tabler/icons-react'; import { ReactNode, useEffect, useRef, useState } from 'react'; -import { PluginContext } from './PluginContext'; +import { InvenTreeContext } from './PluginContext'; import { findExternalPluginFunction } from './PluginSource'; // Definition of the plugin panel properties, provided by the server API @@ -21,7 +21,7 @@ export async function isPluginPanelHidden({ pluginContext }: { pluginProps: PluginPanelProps; - pluginContext: PluginContext; + pluginContext: InvenTreeContext; }): Promise { if (!pluginProps.source) { // No custom source supplied - panel is not hidden @@ -66,7 +66,7 @@ export default function PluginPanelContent({ pluginContext }: Readonly<{ pluginProps: PluginPanelProps; - pluginContext: PluginContext; + pluginContext: InvenTreeContext; }>): ReactNode { const ref = useRef(); diff --git a/src/frontend/src/components/plugins/PluginSource.tsx b/src/frontend/src/components/plugins/PluginSource.tsx index c01f1115aba6..69239866e386 100644 --- a/src/frontend/src/components/plugins/PluginSource.tsx +++ b/src/frontend/src/components/plugins/PluginSource.tsx @@ -36,7 +36,13 @@ export async function loadExternalPluginSource(source: string) { export async function findExternalPluginFunction( source: string, functionName: string -) { +): Promise { + // The source URL may also include the function name divided by a colon + // otherwise the provided function name will be used + if (source.includes(':')) { + [source, functionName] = source.split(':'); + } + const module = await loadExternalPluginSource(source); if (module && module[functionName]) { diff --git a/src/frontend/src/components/plugins/PluginUIFeature.tsx b/src/frontend/src/components/plugins/PluginUIFeature.tsx new file mode 100644 index 000000000000..4ef1fb71abb7 --- /dev/null +++ b/src/frontend/src/components/plugins/PluginUIFeature.tsx @@ -0,0 +1,131 @@ +import { t } from '@lingui/macro'; +import { Alert, Stack, Text } from '@mantine/core'; +import { IconExclamationCircle } from '@tabler/icons-react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; + +import { TemplateI } from '../../tables/settings/TemplateTable'; +import { + EditorComponent, + PreviewAreaComponent, + PreviewAreaRef +} from '../editors/TemplateEditor/TemplateEditor'; +import { + PluginUIFuncWithoutInvenTreeContextType, + TemplateEditorUIFeature, + TemplatePreviewUIFeature +} from './PluginUIFeatureTypes'; + +export const getPluginTemplateEditor = ( + func: PluginUIFuncWithoutInvenTreeContextType, + template: TemplateI +) => + forwardRef((props, ref) => { + const elRef = useRef(); + const [error, setError] = useState(undefined); + + const initialCodeRef = useRef(); + const setCodeRef = useRef<(code: string) => void>(); + const getCodeRef = useRef<() => string>(); + + useImperativeHandle(ref, () => ({ + setCode: (code) => { + // if the editor is not yet initialized, store the initial code in a ref to set it later + if (setCodeRef.current) { + setCodeRef.current(code); + } else { + initialCodeRef.current = code; + } + }, + getCode: () => getCodeRef.current?.() + })); + + useEffect(() => { + (async () => { + try { + await func({ + ref: elRef.current!, + registerHandlers: ({ getCode, setCode }) => { + setCodeRef.current = setCode; + getCodeRef.current = getCode; + + if (initialCodeRef.current) { + setCode(initialCodeRef.current); + } + }, + template + }); + } catch (error) { + setError(t`Error occurred while rendering the template editor.`); + console.error(error); + } + })(); + }, []); + + return ( + + {error && ( + } + > + {error} + + )} +
+
+ ); + }) as EditorComponent; + +export const getPluginTemplatePreview = ( + func: PluginUIFuncWithoutInvenTreeContextType, + template: TemplateI +) => + forwardRef((props, ref) => { + const elRef = useRef(); + const [error, setError] = useState(undefined); + + const updatePreviewRef = useRef(); + + useImperativeHandle(ref, () => ({ + updatePreview: (...args) => updatePreviewRef.current?.(...args) + })); + + useEffect(() => { + (async () => { + try { + await func({ + ref: elRef.current!, + registerHandlers: ({ updatePreview }) => { + updatePreviewRef.current = updatePreview; + }, + template + }); + } catch (error) { + setError(t`Error occurred while rendering the template preview.`); + console.error(error); + } + })(); + }, []); + + return ( + + {error && ( + } + > + {error} + + )} +
+
+ ); + }) as PreviewAreaComponent; diff --git a/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts b/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts new file mode 100644 index 000000000000..f07f56986bf4 --- /dev/null +++ b/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts @@ -0,0 +1,78 @@ +import { ModelType } from '../../enums/ModelType'; +import { InvenTreeIconType } from '../../functions/icons'; +import { TemplateI } from '../../tables/settings/TemplateTable'; +import { TemplateEditorProps } from '../editors/TemplateEditor/TemplateEditor'; +import { InvenTreeContext } from './PluginContext'; + +// #region Type Helpers +export type BaseUIFeature = { + featureType: string; + requestContext: Record; + responseOptions: Record; + featureContext: Record; + featureReturnType: any; +}; + +export type PluginUIGetFeatureType = (params: { + featureContext: T['featureContext']; + inventreeContext: InvenTreeContext; +}) => T['featureReturnType']; + +export type PluginUIFuncWithoutInvenTreeContextType = ( + featureContext: T['featureContext'] +) => T['featureReturnType']; + +export type PluginUIFeatureAPIResponse = { + feature_type: T['featureType']; + options: T['responseOptions']; + source: string; +}; + +// #region Types +export type TemplateEditorUIFeature = { + featureType: 'template_editor'; + requestContext: { + template_type: ModelType.labeltemplate | ModelType.reporttemplate; + template_model: ModelType; + }; + responseOptions: { + key: string; + title: string; + icon: InvenTreeIconType; + }; + featureContext: { + ref: HTMLDivElement; + registerHandlers: (handlers: { + setCode: (code: string) => void; + getCode: () => string; + }) => void; + template: TemplateI; + }; + featureReturnType: void; +}; + +export type TemplatePreviewUIFeature = { + featureType: 'template_preview'; + requestContext: { + template_type: ModelType.labeltemplate | ModelType.reporttemplate; + template_model: ModelType; + }; + responseOptions: { + key: string; + title: string; + icon: InvenTreeIconType; + }; + featureContext: { + ref: HTMLDivElement; + template: TemplateI; + registerHandlers: (handlers: { + updatePreview: ( + code: string, + previewItem: string, + saveTemplate: boolean, + templateEditorProps: TemplateEditorProps + ) => void | Promise; + }) => void; + }; + featureReturnType: void; +}; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 4295f70b0f9a..e15bb9b0bc3f 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -191,6 +191,7 @@ export enum ApiEndpoints { // User interface plugin endpoints plugin_panel_list = 'plugins/ui/panels/', + plugin_ui_features_list = 'plugins/ui/features/:feature_type/', // Machine API endpoints machine_types_list = 'machine/types/', diff --git a/src/frontend/src/hooks/UsePluginPanels.tsx b/src/frontend/src/hooks/UsePluginPanels.tsx index aa676278f407..3f991f86f91c 100644 --- a/src/frontend/src/hooks/UsePluginPanels.tsx +++ b/src/frontend/src/hooks/UsePluginPanels.tsx @@ -1,11 +1,12 @@ -import { useMantineColorScheme, useMantineTheme } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { api } from '../App'; import { PanelType } from '../components/nav/Panel'; -import { PluginContext } from '../components/plugins/PluginContext'; +import { + InvenTreeContext, + useInvenTreeContext +} from '../components/plugins/PluginContext'; import PluginPanelContent, { PluginPanelProps, isPluginPanelHidden @@ -15,12 +16,18 @@ import { ModelType } from '../enums/ModelType'; import { identifierString } from '../functions/conversion'; import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons'; import { apiUrl } from '../states/ApiState'; -import { useLocalState } from '../states/LocalState'; -import { - useGlobalSettingsState, - useUserSettingsState -} from '../states/SettingsState'; -import { useUserState } from '../states/UserState'; +import { useGlobalSettingsState } from '../states/SettingsState'; + +/** + * @param model - The model type for the plugin (e.g. 'part' / 'purchaseorder') + * @param id - The ID (primary key) of the model instance for the plugin + * @param instance - The model instance data (if available) + */ +export type PluginPanelContext = InvenTreeContext & { + model?: ModelType | string; + id?: string | number | null; + instance?: any; +}; export function usePluginPanels({ instance, @@ -31,13 +38,7 @@ export function usePluginPanels({ model?: ModelType | string; id?: string | number | null; }): PanelType[] { - const host = useLocalState.getState().host; - const navigate = useNavigate(); - const user = useUserState(); - const { colorScheme } = useMantineColorScheme(); - const theme = useMantineTheme(); const globalSettings = useGlobalSettingsState(); - const userSettings = useUserSettingsState(); const pluginPanelsEnabled: boolean = useMemo( () => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'), @@ -69,33 +70,15 @@ export function usePluginPanels({ }); // Cache the context data which is delivered to the plugins - const contextData: PluginContext = useMemo(() => { + const inventreeContext = useInvenTreeContext(); + const contextData = useMemo(() => { return { model: model, id: id, instance: instance, - user: user, - host: host, - api: api, - navigate: navigate, - globalSettings: globalSettings, - userSettings: userSettings, - theme: theme, - colorScheme: colorScheme + ...inventreeContext }; - }, [ - model, - id, - instance, - user, - host, - api, - navigate, - globalSettings, - userSettings, - theme, - colorScheme - ]); + }, [model, id, instance]); // Track which panels are hidden: { panelName: true/false } // We need to memoize this as the plugins can determine this dynamically diff --git a/src/frontend/src/hooks/UsePluginUIFeature.tsx b/src/frontend/src/hooks/UsePluginUIFeature.tsx new file mode 100644 index 000000000000..e1b1be9fe51c --- /dev/null +++ b/src/frontend/src/hooks/UsePluginUIFeature.tsx @@ -0,0 +1,90 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { api } from '../App'; +import { useInvenTreeContext } from '../components/plugins/PluginContext'; +import { findExternalPluginFunction } from '../components/plugins/PluginSource'; +import { + BaseUIFeature, + PluginUIFeatureAPIResponse, + PluginUIFuncWithoutInvenTreeContextType +} from '../components/plugins/PluginUIFeatureTypes'; +import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { apiUrl } from '../states/ApiState'; +import { useGlobalSettingsState } from '../states/SettingsState'; + +export function usePluginUIFeature({ + enabled = true, + featureType, + context +}: { + enabled?: boolean; + featureType: UIFeatureT['featureType']; + context: UIFeatureT['requestContext']; +}) { + const globalSettings = useGlobalSettingsState(); + + const pluginUiFeaturesEnabled: boolean = useMemo( + () => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'), + [globalSettings] + ); + + // API query to fetch initial information on available plugin panels + const { data: pluginData } = useQuery< + PluginUIFeatureAPIResponse[] + >({ + enabled: pluginUiFeaturesEnabled && !!featureType && enabled, + queryKey: ['custom-ui-features', featureType, JSON.stringify(context)], + queryFn: async () => { + if (!pluginUiFeaturesEnabled || !featureType) { + return Promise.resolve([]); + } + + return api + .get( + apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, { + feature_type: featureType + }), + { + params: context + } + ) + .then((response: any) => response.data) + .catch((error: any) => { + console.error( + `Failed to fetch plugin ui features for feature "${featureType}":`, + error + ); + return []; + }); + } + }); + + // Cache the context data which is delivered to the plugins + const inventreeContext = useInvenTreeContext(); + + return useMemo< + { + options: UIFeatureT['responseOptions']; + func: PluginUIFuncWithoutInvenTreeContextType; + }[] + >(() => { + return ( + pluginData?.map((feature) => ({ + options: feature.options, + func: (async (featureContext) => { + const func = await findExternalPluginFunction( + feature.source, + 'getFeature' + ); + if (!func) return; + + return func({ + featureContext, + inventreeContext + }); + }) as PluginUIFuncWithoutInvenTreeContextType + })) || [] + ); + }, [pluginData, inventreeContext]); +} diff --git a/src/frontend/src/tables/settings/TemplateTable.tsx b/src/frontend/src/tables/settings/TemplateTable.tsx index 765b3f3f256a..30db4b931b98 100644 --- a/src/frontend/src/tables/settings/TemplateTable.tsx +++ b/src/frontend/src/tables/settings/TemplateTable.tsx @@ -10,11 +10,24 @@ import { PdfPreview, TemplateEditor } from '../../components/editors/TemplateEditor'; +import { + Editor, + PreviewArea +} from '../../components/editors/TemplateEditor/TemplateEditor'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; import { AttachmentLink } from '../../components/items/AttachmentLink'; import { DetailDrawer } from '../../components/nav/DetailDrawer'; +import { + getPluginTemplateEditor, + getPluginTemplatePreview +} from '../../components/plugins/PluginUIFeature'; +import { + TemplateEditorUIFeature, + TemplatePreviewUIFeature +} from '../../components/plugins/PluginUIFeatureTypes'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; +import { GetIcon } from '../../functions/icons'; import { notYetImplemented } from '../../functions/notifications'; import { useFilters } from '../../hooks/UseFilter'; import { @@ -23,6 +36,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -49,7 +63,7 @@ export type TemplateI = { }; export interface TemplateProps { - modelType: ModelType; + modelType: ModelType.labeltemplate | ModelType.reporttemplate; templateEndpoint: ApiEndpoints; printingEndpoint: ApiEndpoints; additionalFormFields?: ApiFormFieldSet; @@ -62,7 +76,7 @@ export function TemplateDrawer({ id: string | number; templateProps: TemplateProps; }>) { - const { templateEndpoint, printingEndpoint } = templateProps; + const { modelType, templateEndpoint, printingEndpoint } = templateProps; const { instance: template, @@ -74,6 +88,62 @@ export function TemplateDrawer({ throwError: true }); + // Editors + const extraEditors = usePluginUIFeature({ + enabled: template?.model_type !== undefined, + featureType: 'template_editor', + context: { template_type: modelType, template_model: template?.model_type! } + }); + const editors = useMemo(() => { + const editors = [CodeEditor]; + + if (!template) { + return editors; + } + + 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) + ) || []) + ); + + return editors; + }, [extraEditors, template]); + + // Previews + const extraPreviews = usePluginUIFeature({ + enabled: template?.model_type !== undefined, + featureType: 'template_preview', + context: { template_type: modelType, template_model: template?.model_type! } + }); + const previews = useMemo(() => { + const previews = [PdfPreview]; + + if (!template) { + return previews; + } + + previews.push( + ...(extraPreviews?.map( + (preview) => + ({ + key: preview.options.key, + name: preview.options.title, + icon: GetIcon(preview.options.icon), + component: getPluginTemplatePreview(preview.func, template) + } as PreviewArea) + ) || []) + ); + + return previews; + }, [extraPreviews, template]); + if (isFetching) { return ; } @@ -100,8 +170,8 @@ export function TemplateDrawer({ templateUrl={apiUrl(templateEndpoint, id)} printingUrl={apiUrl(printingEndpoint)} template={template} - editors={[CodeEditor]} - previewAreas={[PdfPreview]} + editors={editors} + previewAreas={previews} /> ); diff --git a/src/frontend/tests/pui_printing.spec.ts b/src/frontend/tests/pui_printing.spec.ts index dcd46b28f8ef..dedd547bafb4 100644 --- a/src/frontend/tests/pui_printing.spec.ts +++ b/src/frontend/tests/pui_printing.spec.ts @@ -1,6 +1,7 @@ -import { test } from './baseFixtures.js'; +import { expect, test } from './baseFixtures.js'; import { baseUrl } from './defaults.js'; import { doQuickLogin } from './login.js'; +import { setPluginState } from './settings.js'; /* * Test for label printing. @@ -81,8 +82,16 @@ test('PUI - Report Printing', async ({ page }) => { await page.context().close(); }); -test('PUI - Report Editing', async ({ page }) => { - await doQuickLogin(page, 'admin', 'inventree'); +test('PUI - Report Editing', async ({ page, request }) => { + const [username, password] = ['admin', 'inventree']; + await doQuickLogin(page, username, password); + + // activate the sample plugin for this test + await setPluginState({ + request, + plugin: 'sampleui', + state: true + }); // Navigate to the admin center await page.getByRole('button', { name: 'admin' }).click(); @@ -104,5 +113,38 @@ test('PUI - Report Editing', async ({ page }) => { await page.getByText('The preview has been updated').waitFor(); - await page.context().close(); + // Test plugin provided editors + await page.getByRole('tab', { name: 'Sample Template Editor' }).click(); + const textarea = page.locator('#sample-template-editor-textarea'); + const textareaValue = await textarea.inputValue(); + expect(textareaValue).toContain( + `{% trans 'QR Code' %}` + ); + textarea.fill(textareaValue + '\nHello world'); + + // Switch back and forth to see if the changed contents get correctly passed between the hooks + await page.getByRole('tab', { name: 'Code', exact: true }).click(); + await page.getByRole('tab', { name: 'Sample Template Editor' }).click(); + const newTextareaValue = await page + .locator('#sample-template-editor-textarea') + .inputValue(); + expect(newTextareaValue).toMatch(/\nHello world$/); + + // Test plugin provided previews + await page.getByRole('tab', { name: 'Sample Template Preview' }).click(); + await page.getByRole('heading', { name: 'Hello world' }).waitFor(); + const consoleLogPromise = page.waitForEvent('console'); + await page + .getByLabel('split-button-preview-options', { exact: true }) + .click(); + const msg = (await consoleLogPromise).args(); + expect(await msg[0].jsonValue()).toBe('updatePreview'); + expect((await msg[1].jsonValue())[0]).toBe(newTextareaValue); + + // deactivate the sample plugin again after the test + await setPluginState({ + request, + plugin: 'sampleui', + state: false + }); });