2
0
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:
Lukas 2024-09-26 11:59:37 +02:00 committed by GitHub
parent 4d48a10bdd
commit 35362347a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 974 additions and 265 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;
};

View File

@ -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/',

View File

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

View 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]);
}

View File

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

View File

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