mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
UI plugins custom features (#8137)
* initial implementation to let plugins provide custom ui features * provide exportable types * refactor ref into renderContext to make it more generic and support template preview area ui plugins * rename 'renderContext' -> 'featureContext' as not all features may render something * allow to specify the function name via the source file string divided by a colon * Bump api version * add tests * add docs * add docs * debug: workflow * debug: workflow * fix tests * fix tests hopefully * apply suggestions from codereview * trigger: ci * Prove that coverage does not work * Revert "Prove that coverage does not work" This reverts commit 920c58ea6fbadc64caa6885aebfd53ab4f1e97da. * potentially fix test???
This commit is contained in:
parent
4d48a10bdd
commit
35362347a7
@ -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:
|
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:
|
options:
|
||||||
show_bases: False
|
show_bases: False
|
||||||
show_root_heading: 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
|
## Sample Plugin
|
||||||
|
|
||||||
A sample plugin which implements custom user interface functionality is provided in the InvenTree source code:
|
A sample plugin which implements custom user interface functionality is provided in the InvenTree source code:
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v258 - 2024-09-24 : https://github.com/inventree/InvenTree/pull/8163
|
||||||
- Enhances the existing PartScheduling API endpoint
|
- Enhances the existing PartScheduling API endpoint
|
||||||
- Adds a formal DRF serializer to the endpoint
|
- Adds a formal DRF serializer to the endpoint
|
||||||
|
@ -11,15 +11,12 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import permissions, status
|
from rest_framework import permissions, status
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
import plugin.serializers as PluginSerializers
|
import plugin.serializers as PluginSerializers
|
||||||
from common.api import GlobalSettingsPermissions
|
from common.api import GlobalSettingsPermissions
|
||||||
from common.settings import get_global_setting
|
|
||||||
from InvenTree.api import MetadataView
|
from InvenTree.api import MetadataView
|
||||||
from InvenTree.exceptions import log_error
|
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
@ -33,6 +30,7 @@ from plugin import registry
|
|||||||
from plugin.base.action.api import ActionPluginView
|
from plugin.base.action.api import ActionPluginView
|
||||||
from plugin.base.barcodes.api import barcode_api_urls
|
from plugin.base.barcodes.api import barcode_api_urls
|
||||||
from plugin.base.locate.api import LocatePluginView
|
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.models import PluginConfig, PluginSetting
|
||||||
from plugin.plugin import InvenTreePlugin
|
from plugin.plugin import InvenTreePlugin
|
||||||
|
|
||||||
@ -417,43 +415,6 @@ class RegistryStatusView(APIView):
|
|||||||
return Response(result)
|
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):
|
class PluginMetadataView(MetadataView):
|
||||||
"""Metadata API endpoint for the PluginConfig model."""
|
"""Metadata API endpoint for the PluginConfig model."""
|
||||||
|
|
||||||
@ -468,21 +429,8 @@ plugin_api_urls = [
|
|||||||
path(
|
path(
|
||||||
'plugins/',
|
'plugins/',
|
||||||
include([
|
include([
|
||||||
path(
|
# UI plugins
|
||||||
'ui/',
|
path('ui/', include(ui_plugins_api_urls)),
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'panels/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'',
|
|
||||||
PluginPanelList.as_view(),
|
|
||||||
name='api-plugin-panel-list',
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Plugin management
|
# Plugin management
|
||||||
path('reload/', PluginReload.as_view(), name='api-plugin-reload'),
|
path('reload/', PluginReload.as_view(), name='api-plugin-reload'),
|
||||||
path('install/', PluginInstall.as_view(), name='api-plugin-install'),
|
path('install/', PluginInstall.as_view(), name='api-plugin-install'),
|
||||||
|
@ -8,8 +8,7 @@ from django.urls import include, path, re_path, reverse
|
|||||||
|
|
||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
|
||||||
from plugin import InvenTreePlugin
|
from plugin import InvenTreePlugin
|
||||||
from plugin.base.integration.PanelMixin import PanelMixin
|
from plugin.base.integration.PanelMixin import PanelMixin
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
@ -479,100 +478,3 @@ class PanelMixinTests(InvenTreeTestCase):
|
|||||||
|
|
||||||
plugin = Wrong()
|
plugin = Wrong()
|
||||||
plugin.get_custom_panels('abc', 'abc')
|
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)
|
|
||||||
|
0
src/backend/InvenTree/plugin/base/ui/__init__.py
Normal file
0
src/backend/InvenTree/plugin/base/ui/__init__.py
Normal file
94
src/backend/InvenTree/plugin/base/ui/api.py
Normal file
94
src/backend/InvenTree/plugin/base/ui/api.py
Normal file
@ -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/<str:feature>/',
|
||||||
|
PluginUIFeatureList.as_view(),
|
||||||
|
name='api-plugin-ui-feature-list',
|
||||||
|
),
|
||||||
|
]
|
@ -4,7 +4,7 @@ Allows integration of custom UI elements into the React user interface.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TypedDict
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
|
||||||
@ -29,6 +29,23 @@ class CustomPanel(TypedDict):
|
|||||||
source: str
|
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:
|
class UserInterfaceMixin:
|
||||||
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
|
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
|
||||||
|
|
||||||
@ -48,7 +65,7 @@ class UserInterfaceMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('ui', True, __class__)
|
self.add_mixin('ui', True, __class__) # type: ignore
|
||||||
|
|
||||||
def get_ui_panels(
|
def get_ui_panels(
|
||||||
self, instance_type: str, instance_id: int, request: Request, **kwargs
|
self, instance_type: str, instance_id: int, request: Request, **kwargs
|
||||||
@ -78,3 +95,19 @@ class UserInterfaceMixin:
|
|||||||
"""
|
"""
|
||||||
# Default implementation returns an empty list
|
# Default implementation returns an empty list
|
||||||
return []
|
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 []
|
68
src/backend/InvenTree/plugin/base/ui/serializers.py
Normal file
68
src/backend/InvenTree/plugin/base/ui/serializers.py
Normal file
@ -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
|
||||||
|
)
|
157
src/backend/InvenTree/plugin/base/ui/tests.py
Normal file
157
src/backend/InvenTree/plugin/base/ui/tests.py
Normal file
@ -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)
|
@ -14,10 +14,10 @@ from plugin.base.integration.ReportMixin import ReportMixin
|
|||||||
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
||||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||||
from plugin.base.integration.UrlsMixin import UrlsMixin
|
from plugin.base.integration.UrlsMixin import UrlsMixin
|
||||||
from plugin.base.integration.UserInterfaceMixin import UserInterfaceMixin
|
|
||||||
from plugin.base.integration.ValidationMixin import ValidationMixin
|
from plugin.base.integration.ValidationMixin import ValidationMixin
|
||||||
from plugin.base.label.mixins import LabelPrintingMixin
|
from plugin.base.label.mixins import LabelPrintingMixin
|
||||||
from plugin.base.locate.mixins import LocateMixin
|
from plugin.base.locate.mixins import LocateMixin
|
||||||
|
from plugin.base.ui.mixins import UserInterfaceMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'APICallMixin',
|
'APICallMixin',
|
||||||
|
@ -122,3 +122,36 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
})
|
})
|
||||||
|
|
||||||
return panels
|
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 []
|
||||||
|
@ -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 = "<h1>Hello world</h1>";
|
||||||
|
}
|
@ -301,46 +301,3 @@ class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
|
|||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
"""Return the 'key' of the PluginConfig object."""
|
"""Return the 'key' of the PluginConfig object."""
|
||||||
return value.key
|
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
|
|
||||||
)
|
|
||||||
|
@ -52,7 +52,7 @@ export type Editor = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PreviewAreaProps = {};
|
type PreviewAreaProps = {};
|
||||||
type PreviewAreaRef = {
|
export type PreviewAreaRef = {
|
||||||
updatePreview: (
|
updatePreview: (
|
||||||
code: string,
|
code: string,
|
||||||
previewItem: string,
|
previewItem: string,
|
||||||
@ -300,6 +300,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={previewValue}
|
value={previewValue}
|
||||||
onChange={setPreviewValue}
|
onChange={setPreviewValue}
|
||||||
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -1,18 +1,26 @@
|
|||||||
import { MantineColorScheme, MantineTheme } from '@mantine/core';
|
import {
|
||||||
|
MantineColorScheme,
|
||||||
|
MantineTheme,
|
||||||
|
useMantineColorScheme,
|
||||||
|
useMantineTheme
|
||||||
|
} from '@mantine/core';
|
||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { NavigateFunction } from 'react-router-dom';
|
import { useMemo } from 'react';
|
||||||
|
import { NavigateFunction, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { api } from '../../App';
|
||||||
import { SettingsStateProps } from '../../states/SettingsState';
|
import { useLocalState } from '../../states/LocalState';
|
||||||
import { UserStateProps } from '../../states/UserState';
|
import {
|
||||||
|
SettingsStateProps,
|
||||||
|
useGlobalSettingsState,
|
||||||
|
useUserSettingsState
|
||||||
|
} from '../../states/SettingsState';
|
||||||
|
import { UserStateProps, useUserState } from '../../states/UserState';
|
||||||
|
|
||||||
/*
|
/**
|
||||||
* A set of properties which are passed to a plugin,
|
* A set of properties which are passed to a plugin,
|
||||||
* for rendering an element in the user interface.
|
* for rendering an element in the user interface.
|
||||||
*
|
*
|
||||||
* @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)
|
|
||||||
* @param api - The Axios API instance (see ../states/ApiState.tsx)
|
* @param api - The Axios API instance (see ../states/ApiState.tsx)
|
||||||
* @param user - The current user instance (see ../states/UserState.tsx)
|
* @param user - The current user instance (see ../states/UserState.tsx)
|
||||||
* @param userSettings - The current user settings (see ../states/SettingsState.tsx)
|
* @param userSettings - The current user settings (see ../states/SettingsState.tsx)
|
||||||
@ -21,10 +29,7 @@ import { UserStateProps } from '../../states/UserState';
|
|||||||
* @param theme - The current Mantine theme
|
* @param theme - The current Mantine theme
|
||||||
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
|
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
|
||||||
*/
|
*/
|
||||||
export type PluginContext = {
|
export type InvenTreeContext = {
|
||||||
model?: ModelType | string;
|
|
||||||
id?: string | number | null;
|
|
||||||
instance?: any;
|
|
||||||
api: AxiosInstance;
|
api: AxiosInstance;
|
||||||
user: UserStateProps;
|
user: UserStateProps;
|
||||||
userSettings: SettingsStateProps;
|
userSettings: SettingsStateProps;
|
||||||
@ -34,3 +39,37 @@ export type PluginContext = {
|
|||||||
theme: MantineTheme;
|
theme: MantineTheme;
|
||||||
colorScheme: MantineColorScheme;
|
colorScheme: MantineColorScheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useInvenTreeContext = () => {
|
||||||
|
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<InvenTreeContext>(() => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
@ -3,7 +3,7 @@ import { Alert, Stack, Text } from '@mantine/core';
|
|||||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { PluginContext } from './PluginContext';
|
import { InvenTreeContext } from './PluginContext';
|
||||||
import { findExternalPluginFunction } from './PluginSource';
|
import { findExternalPluginFunction } from './PluginSource';
|
||||||
|
|
||||||
// Definition of the plugin panel properties, provided by the server API
|
// Definition of the plugin panel properties, provided by the server API
|
||||||
@ -21,7 +21,7 @@ export async function isPluginPanelHidden({
|
|||||||
pluginContext
|
pluginContext
|
||||||
}: {
|
}: {
|
||||||
pluginProps: PluginPanelProps;
|
pluginProps: PluginPanelProps;
|
||||||
pluginContext: PluginContext;
|
pluginContext: InvenTreeContext;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
if (!pluginProps.source) {
|
if (!pluginProps.source) {
|
||||||
// No custom source supplied - panel is not hidden
|
// No custom source supplied - panel is not hidden
|
||||||
@ -66,7 +66,7 @@ export default function PluginPanelContent({
|
|||||||
pluginContext
|
pluginContext
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
pluginProps: PluginPanelProps;
|
pluginProps: PluginPanelProps;
|
||||||
pluginContext: PluginContext;
|
pluginContext: InvenTreeContext;
|
||||||
}>): ReactNode {
|
}>): ReactNode {
|
||||||
const ref = useRef<HTMLDivElement>();
|
const ref = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
@ -36,7 +36,13 @@ export async function loadExternalPluginSource(source: string) {
|
|||||||
export async function findExternalPluginFunction(
|
export async function findExternalPluginFunction(
|
||||||
source: string,
|
source: string,
|
||||||
functionName: string
|
functionName: string
|
||||||
) {
|
): Promise<Function | null> {
|
||||||
|
// 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);
|
const module = await loadExternalPluginSource(source);
|
||||||
|
|
||||||
if (module && module[functionName]) {
|
if (module && module[functionName]) {
|
||||||
|
131
src/frontend/src/components/plugins/PluginUIFeature.tsx
Normal file
131
src/frontend/src/components/plugins/PluginUIFeature.tsx
Normal file
@ -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<TemplateEditorUIFeature>,
|
||||||
|
template: TemplateI
|
||||||
|
) =>
|
||||||
|
forwardRef((props, ref) => {
|
||||||
|
const elRef = useRef<HTMLDivElement>();
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const initialCodeRef = useRef<string>();
|
||||||
|
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 (
|
||||||
|
<Stack gap="xs" style={{ display: 'flex', flex: 1 }}>
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
title={t`Error Loading Plugin Editor`}
|
||||||
|
icon={<IconExclamationCircle />}
|
||||||
|
>
|
||||||
|
<Text>{error}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div ref={elRef as any} style={{ display: 'flex', flex: 1 }}></div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}) as EditorComponent;
|
||||||
|
|
||||||
|
export const getPluginTemplatePreview = (
|
||||||
|
func: PluginUIFuncWithoutInvenTreeContextType<TemplatePreviewUIFeature>,
|
||||||
|
template: TemplateI
|
||||||
|
) =>
|
||||||
|
forwardRef((props, ref) => {
|
||||||
|
const elRef = useRef<HTMLDivElement>();
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const updatePreviewRef = useRef<PreviewAreaRef['updatePreview']>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Stack gap="xs" style={{ display: 'flex', flex: 1 }}>
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
title={t`Error Loading Plugin Preview`}
|
||||||
|
icon={<IconExclamationCircle />}
|
||||||
|
>
|
||||||
|
<Text>{error}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div ref={elRef as any} style={{ display: 'flex', flex: 1 }}></div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}) as PreviewAreaComponent;
|
78
src/frontend/src/components/plugins/PluginUIFeatureTypes.ts
Normal file
78
src/frontend/src/components/plugins/PluginUIFeatureTypes.ts
Normal file
@ -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<string, any>;
|
||||||
|
responseOptions: Record<string, any>;
|
||||||
|
featureContext: Record<string, any>;
|
||||||
|
featureReturnType: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginUIGetFeatureType<T extends BaseUIFeature> = (params: {
|
||||||
|
featureContext: T['featureContext'];
|
||||||
|
inventreeContext: InvenTreeContext;
|
||||||
|
}) => T['featureReturnType'];
|
||||||
|
|
||||||
|
export type PluginUIFuncWithoutInvenTreeContextType<T extends BaseUIFeature> = (
|
||||||
|
featureContext: T['featureContext']
|
||||||
|
) => T['featureReturnType'];
|
||||||
|
|
||||||
|
export type PluginUIFeatureAPIResponse<T extends BaseUIFeature> = {
|
||||||
|
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>;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
featureReturnType: void;
|
||||||
|
};
|
@ -191,6 +191,7 @@ export enum ApiEndpoints {
|
|||||||
|
|
||||||
// User interface plugin endpoints
|
// User interface plugin endpoints
|
||||||
plugin_panel_list = 'plugins/ui/panels/',
|
plugin_panel_list = 'plugins/ui/panels/',
|
||||||
|
plugin_ui_features_list = 'plugins/ui/features/:feature_type/',
|
||||||
|
|
||||||
// Machine API endpoints
|
// Machine API endpoints
|
||||||
machine_types_list = 'machine/types/',
|
machine_types_list = 'machine/types/',
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { api } from '../App';
|
import { api } from '../App';
|
||||||
import { PanelType } from '../components/nav/Panel';
|
import { PanelType } from '../components/nav/Panel';
|
||||||
import { PluginContext } from '../components/plugins/PluginContext';
|
import {
|
||||||
|
InvenTreeContext,
|
||||||
|
useInvenTreeContext
|
||||||
|
} from '../components/plugins/PluginContext';
|
||||||
import PluginPanelContent, {
|
import PluginPanelContent, {
|
||||||
PluginPanelProps,
|
PluginPanelProps,
|
||||||
isPluginPanelHidden
|
isPluginPanelHidden
|
||||||
@ -15,12 +16,18 @@ import { ModelType } from '../enums/ModelType';
|
|||||||
import { identifierString } from '../functions/conversion';
|
import { identifierString } from '../functions/conversion';
|
||||||
import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons';
|
import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
import { useLocalState } from '../states/LocalState';
|
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||||
import {
|
|
||||||
useGlobalSettingsState,
|
/**
|
||||||
useUserSettingsState
|
* @param model - The model type for the plugin (e.g. 'part' / 'purchaseorder')
|
||||||
} from '../states/SettingsState';
|
* @param id - The ID (primary key) of the model instance for the plugin
|
||||||
import { useUserState } from '../states/UserState';
|
* @param instance - The model instance data (if available)
|
||||||
|
*/
|
||||||
|
export type PluginPanelContext = InvenTreeContext & {
|
||||||
|
model?: ModelType | string;
|
||||||
|
id?: string | number | null;
|
||||||
|
instance?: any;
|
||||||
|
};
|
||||||
|
|
||||||
export function usePluginPanels({
|
export function usePluginPanels({
|
||||||
instance,
|
instance,
|
||||||
@ -31,13 +38,7 @@ export function usePluginPanels({
|
|||||||
model?: ModelType | string;
|
model?: ModelType | string;
|
||||||
id?: string | number | null;
|
id?: string | number | null;
|
||||||
}): PanelType[] {
|
}): PanelType[] {
|
||||||
const host = useLocalState.getState().host;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const user = useUserState();
|
|
||||||
const { colorScheme } = useMantineColorScheme();
|
|
||||||
const theme = useMantineTheme();
|
|
||||||
const globalSettings = useGlobalSettingsState();
|
const globalSettings = useGlobalSettingsState();
|
||||||
const userSettings = useUserSettingsState();
|
|
||||||
|
|
||||||
const pluginPanelsEnabled: boolean = useMemo(
|
const pluginPanelsEnabled: boolean = useMemo(
|
||||||
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||||
@ -69,33 +70,15 @@ export function usePluginPanels({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cache the context data which is delivered to the plugins
|
// Cache the context data which is delivered to the plugins
|
||||||
const contextData: PluginContext = useMemo(() => {
|
const inventreeContext = useInvenTreeContext();
|
||||||
|
const contextData = useMemo<PluginPanelContext>(() => {
|
||||||
return {
|
return {
|
||||||
model: model,
|
model: model,
|
||||||
id: id,
|
id: id,
|
||||||
instance: instance,
|
instance: instance,
|
||||||
user: user,
|
...inventreeContext
|
||||||
host: host,
|
|
||||||
api: api,
|
|
||||||
navigate: navigate,
|
|
||||||
globalSettings: globalSettings,
|
|
||||||
userSettings: userSettings,
|
|
||||||
theme: theme,
|
|
||||||
colorScheme: colorScheme
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [model, id, instance]);
|
||||||
model,
|
|
||||||
id,
|
|
||||||
instance,
|
|
||||||
user,
|
|
||||||
host,
|
|
||||||
api,
|
|
||||||
navigate,
|
|
||||||
globalSettings,
|
|
||||||
userSettings,
|
|
||||||
theme,
|
|
||||||
colorScheme
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Track which panels are hidden: { panelName: true/false }
|
// Track which panels are hidden: { panelName: true/false }
|
||||||
// We need to memoize this as the plugins can determine this dynamically
|
// We need to memoize this as the plugins can determine this dynamically
|
||||||
|
90
src/frontend/src/hooks/UsePluginUIFeature.tsx
Normal file
90
src/frontend/src/hooks/UsePluginUIFeature.tsx
Normal file
@ -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<UIFeatureT extends BaseUIFeature>({
|
||||||
|
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<UIFeatureT>[]
|
||||||
|
>({
|
||||||
|
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<UIFeatureT>;
|
||||||
|
}[]
|
||||||
|
>(() => {
|
||||||
|
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<UIFeatureT>
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}, [pluginData, inventreeContext]);
|
||||||
|
}
|
@ -10,11 +10,24 @@ import {
|
|||||||
PdfPreview,
|
PdfPreview,
|
||||||
TemplateEditor
|
TemplateEditor
|
||||||
} from '../../components/editors/TemplateEditor';
|
} from '../../components/editors/TemplateEditor';
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
PreviewArea
|
||||||
|
} from '../../components/editors/TemplateEditor/TemplateEditor';
|
||||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
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 { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import { GetIcon } from '../../functions/icons';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
import { notYetImplemented } from '../../functions/notifications';
|
||||||
import { useFilters } from '../../hooks/UseFilter';
|
import { useFilters } from '../../hooks/UseFilter';
|
||||||
import {
|
import {
|
||||||
@ -23,6 +36,7 @@ import {
|
|||||||
useEditApiFormModal
|
useEditApiFormModal
|
||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
|
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
@ -49,7 +63,7 @@ export type TemplateI = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface TemplateProps {
|
export interface TemplateProps {
|
||||||
modelType: ModelType;
|
modelType: ModelType.labeltemplate | ModelType.reporttemplate;
|
||||||
templateEndpoint: ApiEndpoints;
|
templateEndpoint: ApiEndpoints;
|
||||||
printingEndpoint: ApiEndpoints;
|
printingEndpoint: ApiEndpoints;
|
||||||
additionalFormFields?: ApiFormFieldSet;
|
additionalFormFields?: ApiFormFieldSet;
|
||||||
@ -62,7 +76,7 @@ export function TemplateDrawer({
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
templateProps: TemplateProps;
|
templateProps: TemplateProps;
|
||||||
}>) {
|
}>) {
|
||||||
const { templateEndpoint, printingEndpoint } = templateProps;
|
const { modelType, templateEndpoint, printingEndpoint } = templateProps;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
instance: template,
|
instance: template,
|
||||||
@ -74,6 +88,62 @@ export function TemplateDrawer({
|
|||||||
throwError: true
|
throwError: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Editors
|
||||||
|
const extraEditors = usePluginUIFeature<TemplateEditorUIFeature>({
|
||||||
|
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<TemplatePreviewUIFeature>({
|
||||||
|
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) {
|
if (isFetching) {
|
||||||
return <LoadingOverlay visible={true} />;
|
return <LoadingOverlay visible={true} />;
|
||||||
}
|
}
|
||||||
@ -100,8 +170,8 @@ export function TemplateDrawer({
|
|||||||
templateUrl={apiUrl(templateEndpoint, id)}
|
templateUrl={apiUrl(templateEndpoint, id)}
|
||||||
printingUrl={apiUrl(printingEndpoint)}
|
printingUrl={apiUrl(printingEndpoint)}
|
||||||
template={template}
|
template={template}
|
||||||
editors={[CodeEditor]}
|
editors={editors}
|
||||||
previewAreas={[PdfPreview]}
|
previewAreas={previews}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { test } from './baseFixtures.js';
|
import { expect, test } from './baseFixtures.js';
|
||||||
import { baseUrl } from './defaults.js';
|
import { baseUrl } from './defaults.js';
|
||||||
import { doQuickLogin } from './login.js';
|
import { doQuickLogin } from './login.js';
|
||||||
|
import { setPluginState } from './settings.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Test for label printing.
|
* Test for label printing.
|
||||||
@ -81,8 +82,16 @@ test('PUI - Report Printing', async ({ page }) => {
|
|||||||
await page.context().close();
|
await page.context().close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PUI - Report Editing', async ({ page }) => {
|
test('PUI - Report Editing', async ({ page, request }) => {
|
||||||
await doQuickLogin(page, 'admin', 'inventree');
|
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
|
// Navigate to the admin center
|
||||||
await page.getByRole('button', { name: 'admin' }).click();
|
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.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(
|
||||||
|
`<img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>`
|
||||||
|
);
|
||||||
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user