diff --git a/docs/docs/extend/plugins/ui.md b/docs/docs/extend/plugins/ui.md
index e38482be27..16988ca2e3 100644
--- a/docs/docs/extend/plugins/ui.md
+++ b/docs/docs/extend/plugins/ui.md
@@ -4,19 +4,104 @@ title: User Interface Mixin
## User Interface Mixin
-The *User Interface* mixin class provides a set of methods to implement custom functionality for the InvenTree web interface.
+The `UserInterfaceMixin` class provides a set of methods to implement custom functionality for the InvenTree web interface.
### Enable User Interface Mixin
To enable user interface plugins, the global setting `ENABLE_PLUGINS_INTERFACE` must be enabled, in the [plugin settings](../../settings/global.md#plugin-settings).
-## Plugin Context
+## Custom UI Features
-When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
+The InvenTree user interface functionality can be extended in various ways using plugins. Multiple types of user interface *features* can be added to the InvenTree user interface.
-{{ includefile("src/frontend/src/components/plugins/PluginContext.tsx", title="Plugin Context", fmt="javascript") }}
+The entrypoint for user interface plugins is the `UserInterfaceMixin` class, which provides a number of methods which can be overridden to provide custom functionality. The `get_ui_features` method is used to extract available user interface features from the plugin:
-## Custom Panels
+::: 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: []
+
+Note here that the `get_ui_features` calls other methods to extract the available features from the plugin, based on the requested feature type. These methods can be overridden to provide custom functionality.
+
+!!! info "Implementation"
+ Your custom plugin does not need to override the `get_ui_features` method. Instead, override one of the other methods to provide custom functionality.
+
+### UIFeature Return Type
+
+The `get_ui_features` method should return a list of `UIFeature` objects, which define the available user interface features for the plugin. The `UIFeature` class is defined as follows:
+
+::: plugin.base.ui.mixins.UIFeature
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_sources: True
+ summary: False
+ members: []
+
+Note that the *options* field contains fields which may be specific to a particular feature type - read the documentation below on each feature type for more information.
+
+### Dynamic Feature Loading
+
+Each of the provided feature types can be loaded dynamically by the plugin, based on the information provided in the API request. For example, the plugin can choose to show or hide a particular feature based on the user permissions, or the current state of the system.
+
+For examples of this dynamic feature loading, refer to the [sample plugin](#sample-plugin) implementation which demonstrates how to dynamically load custom panels based on the provided context.
+
+### Javascript Source Files
+
+The rendering function for the custom user interface features expect that the plugin provides a Javascript source file which contains the necessary code to render the custom content. The path to this file should be provided in the `source` field of the `UIFeature` object.
+
+Note that the `source` field can include the name of the function to be called (if this differs from the expected default function name).
+
+For example:
+
+```
+"source": "/static/plugins/my_plugin/my_plugin.js:my_custom_function"
+```
+
+## Available UI Feature Types
+
+The following user interface feature types are available:
+
+### Dashboard Items
+
+The InvenTree dashboard is a collection of "items" which are displayed on the main dashboard page. Custom dashboard items can be added to the dashboard by implementing the `get_ui_dashboard_items` method:
+
+::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_dashboard_items
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_sources: True
+ summary: False
+ members: []
+
+#### Dashboard Item Options
+
+The *options* field in the returned `UIFeature` object can contain the following properties:
+
+::: plugin.base.ui.mixins.CustomDashboardItemOptions
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_sources: True
+ summary: False
+ members: []
+
+#### Source Function
+
+The frontend code expects a path to a javascript file containing a function named `renderDashboardItem` which will be called to render the custom dashboard item. Note that this function name can be overridden by appending the function name in the `source` field of the `UIFeature` object.
+
+#### Example
+
+Refer to the [sample plugin](#sample-plugin) for an example of how to implement server side rendering for custom panels.
+
+### Panels
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:
@@ -29,71 +114,11 @@ Many of the pages in the InvenTree web interface are built using a series of "pa
summary: False
members: []
-The custom panels can display content which is generated either on the server side, or on the client side (see below).
+#### Panel Options
-### Server Side Rendering
+The *options* field in the returned `UIFeature` object can contain the following properties:
-The panel content can be generated on the server side, by returning a 'content' attribute in the response. This 'content' attribute is expected to be raw HTML, and is rendered directly into the page. This is particularly useful for displaying static content.
-
-Server-side rendering is simple to implement, and can make use of the powerful Django templating system.
-
-Refer to the [sample plugin](#sample-plugin) for an example of how to implement server side rendering for custom panels.
-
-**Advantages:**
-
-- Simple to implement
-- Can use Django templates to render content
-- Has access to the full InvenTree database, and content not available on the client side (via the API)
-
-**Disadvantages:**
-
-- Content is rendered on the server side, and cannot be updated without a page refresh
-- Content is not interactive
-
-### Client Side Rendering
-
-The panel content can also be generated on the client side, by returning a 'source' attribute in the response. This 'source' attribute is expected to be a URL which points to a JavaScript file which will be loaded by the client.
-
-Refer to the [sample plugin](#sample-plugin) for an example of how to implement client side rendering for custom panels.
-
-#### Panel Render Function
-
-The JavaScript file must implement a `renderPanel` function, which is called by the client when the panel is rendered. This function is passed two parameters:
-
-- `target`: The HTML element which the panel content should be rendered into
-- `context`: A dictionary of context data which can be used to render the panel content
-
-
-**Example**
-
-```javascript
-export function renderPanel(target, context) {
- target.innerHTML = "
Hello, world!
";
-}
-```
-
-#### Panel Visibility Function
-
-The JavaScript file can also implement a `isPanelHidden` function, which is called by the client to determine if the panel is displayed. This function is passed a single parameter, *context* - which is the same as the context data passed to the `renderPanel` function.
-
-The `isPanelHidden` function should return a boolean value, which determines if the panel is displayed or not, based on the context data.
-
-If the `isPanelHidden` function is not implemented, the panel will be displayed by default.
-
-**Example**
-
-```javascript
-export function isPanelHidden(context) {
- // Only visible for active parts
- return context.model == 'part' && context.instance?.active;
-}
-```
-
-## 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
+::: plugin.base.ui.mixins.CustomPanelOptions
options:
show_bases: False
show_root_heading: False
@@ -102,36 +127,55 @@ User interface plugins can also provide additional user interface functions. The
summary: False
members: []
-::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin.get_ui_features
+#### Source Function
+
+The frontend code expects a path to a javascript file containing a function named `renderPanel` which will be called to render the custom panel. Note that this function name can be overridden by appending the function name in the `source` field of the `UIFeature` object.
+
+#### Example
+
+Refer to the [sample plugin](#sample-plugin) for an example of how to implement server side rendering for custom panels.
+
+### Template Editors
+
+The `get_ui_template_editors` feature type can be used to provide custom template editors.
+
+::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_template_editors
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") }}
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_sources: True
+ summary: False
+ members: []
### Template previews
-The `template_preview` feature type can be used to provide custom template previews. For an example see:
+The `get_ui_template_previews` feature type can be used to provide custom template previews:
-**Example:**
+::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_template_previews
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_sources: True
+ summary: False
+ members: []
-{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }}
+## Plugin Context
+
+When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
+
+{{ includefile("src/frontend/src/components/plugins/PluginContext.tsx", title="Plugin Context", fmt="javascript") }}
+
+This context data can be used to provide additional information to the rendering functions, and can be used to dynamically render content based on the current state of the system.
+
+### Additional Context
+
+Note that additional context can be passed to the rendering functions by adding additional key-value pairs to the `context` field in the `UIFeature` return type (provided by the backend via the API). This field is optional, and can be used at the discretion of the plugin developer.
## 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, which provides a full working example of how to implement custom user interface functionality.
::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin
options:
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index fcd5a44515..995e0cd4c8 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 276
+INVENTREE_API_VERSION = 277
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v277 - 2024-11-01 : https://github.com/inventree/InvenTree/pull/8278
+ - Allow build order list to be filtered by "outstanding" (alias for "active")
+
v276 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8403
- Adds 'destination' field to the PurchaseOrder model and API endpoints
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index f83b149c21..d65d8980b9 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -40,6 +40,9 @@ class BuildFilter(rest_filters.FilterSet):
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
+ # 'outstanding' is an alias for 'active' here
+ outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active')
+
def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active."""
if str2bool(value):
diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py
index e8b202e068..a6a463fdc3 100644
--- a/src/backend/InvenTree/build/test_api.py
+++ b/src/backend/InvenTree/build/test_api.py
@@ -451,6 +451,10 @@ class BuildTest(BuildAPITest):
# Now, let's delete each build output individually via the API
outputs = bo.build_outputs.all()
+ # Assert that each output is currently in production
+ for output in outputs:
+ self.assertTrue(output.is_building)
+
delete_url = reverse('api-build-output-delete', kwargs={'pk': 1})
response = self.post(
diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py
index 7891eeb9fe..fb57bfa67d 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -895,7 +895,9 @@ class BaseInvenTreeSetting(models.Model):
except ValidationError as e:
raise e
except Exception:
- raise ValidationError({'value': _('Invalid value')})
+ raise ValidationError({
+ 'value': _('Value does not pass validation checks')
+ })
def validate_unique(self, exclude=None):
"""Ensure that the key:value pair is unique. In addition to the base validators, this ensures that the 'key' is unique, using a case-insensitive comparison.
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 5e3c05d09b..bf35232746 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -196,7 +196,9 @@ class PurchaseOrderMixin:
"""Return the annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
- queryset = queryset.prefetch_related('supplier', 'lines')
+ queryset = queryset.prefetch_related(
+ 'supplier', 'project_code', 'lines', 'responsible'
+ )
queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
@@ -671,7 +673,9 @@ class SalesOrderMixin:
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
- queryset = queryset.prefetch_related('customer', 'lines')
+ queryset = queryset.prefetch_related(
+ 'customer', 'responsible', 'project_code', 'lines'
+ )
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
@@ -1244,7 +1248,9 @@ class ReturnOrderMixin:
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
- queryset = queryset.prefetch_related('customer')
+ queryset = queryset.prefetch_related(
+ 'customer', 'lines', 'project_code', 'responsible'
+ )
queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)
diff --git a/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py b/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py
index 8eea165c3b..7d5b04a41f 100644
--- a/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py
+++ b/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py
@@ -105,3 +105,32 @@ class SettingsMixin:
return PluginSetting.check_all_settings(
settings_definition=self.settings, plugin=self.plugin_config()
)
+
+ def get_settings_dict(self) -> dict:
+ """Return a dictionary of all settings for this plugin.
+
+ - For each setting, return : pair.
+ - If the setting is not defined, return the default value (if defined).
+
+ Returns:
+ dict: Dictionary of all settings for this plugin
+ """
+ from plugin.models import PluginSetting
+
+ keys = self.settings.keys()
+
+ settings = PluginSetting.objects.filter(
+ plugin=self.plugin_config(), key__in=keys
+ )
+
+ settings_dict = {}
+
+ for setting in settings:
+ settings_dict[setting.key] = setting.value
+
+ # Add any missing settings
+ for key in keys:
+ if key not in settings_dict:
+ settings_dict[key] = self.settings[key].get('default')
+
+ return settings_dict
diff --git a/src/backend/InvenTree/plugin/base/ui/api.py b/src/backend/InvenTree/plugin/base/ui/api.py
index ad85010a29..63916e0d89 100644
--- a/src/backend/InvenTree/plugin/base/ui/api.py
+++ b/src/backend/InvenTree/plugin/base/ui/api.py
@@ -13,47 +13,6 @@ from InvenTree.exceptions import log_error
from plugin import registry
-class PluginPanelList(APIView):
- """API endpoint for listing all available plugin panels."""
-
- permission_classes = [IsAuthenticated]
- serializer_class = UIPluginSerializers.PluginPanelSerializer
-
- @extend_schema(
- responses={200: UIPluginSerializers.PluginPanelSerializer(many=True)}
- )
- def get(self, request):
- """Show available plugin panels."""
- target_model = request.query_params.get('target_model', None)
- target_id = request.query_params.get('target_id', None)
-
- panels = []
-
- if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
- # Extract all plugins from the registry which provide custom panels
- for _plugin in registry.with_mixin('ui', active=True):
- try:
- # Allow plugins to fill this data out
- plugin_panels = _plugin.get_ui_panels(
- target_model, target_id, request
- )
-
- if plugin_panels and type(plugin_panels) is list:
- for panel in plugin_panels:
- panel['plugin'] = _plugin.slug
-
- # TODO: Validate each panel before inserting
- panels.append(panel)
- except Exception:
- # Custom panels could not load
- # Log the error and continue
- log_error(f'{_plugin.slug}.get_ui_panels')
-
- return Response(
- UIPluginSerializers.PluginPanelSerializer(panels, many=True).data
- )
-
-
class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features."""
@@ -71,24 +30,49 @@ class PluginUIFeatureList(APIView):
# Extract all plugins from the registry which provide custom ui features
for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out
- plugin_features = _plugin.get_ui_features(
- feature, request.query_params, request
- )
+
+ try:
+ plugin_features = _plugin.get_ui_features(
+ feature, request.query_params, request
+ )
+ except Exception:
+ # Custom features could not load for this plugin
+ # Log the error and continue
+ log_error(f'{_plugin.slug}.get_ui_features')
+ continue
if plugin_features and type(plugin_features) is list:
for _feature in plugin_features:
- features.append(_feature)
+ try:
+ # Ensure that the required fields are present
+ _feature['plugin_name'] = _plugin.slug
+ _feature['feature_type'] = str(feature)
- return Response(
- UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data
- )
+ # Ensure base fields are strings
+ for field in ['key', 'title', 'description', 'source']:
+ if field in _feature:
+ _feature[field] = str(_feature[field])
+
+ # Add the feature to the list (serialize)
+ features.append(
+ UIPluginSerializers.PluginUIFeatureSerializer(
+ _feature, many=False
+ ).data
+ )
+
+ except Exception:
+ # Custom features could not load
+ # Log the error and continue
+ log_error(f'{_plugin.slug}.get_ui_features')
+ continue
+
+ return Response(features)
ui_plugins_api_urls = [
- path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'),
path(
'features//',
PluginUIFeatureList.as_view(),
name='api-plugin-ui-feature-list',
- ),
+ )
]
diff --git a/src/backend/InvenTree/plugin/base/ui/mixins.py b/src/backend/InvenTree/plugin/base/ui/mixins.py
index 21df4688a6..2713a03875 100644
--- a/src/backend/InvenTree/plugin/base/ui/mixins.py
+++ b/src/backend/InvenTree/plugin/base/ui/mixins.py
@@ -11,43 +11,59 @@ from rest_framework.request import Request
logger = logging.getLogger('inventree')
-class CustomPanel(TypedDict):
- """Type definition for a custom panel.
-
- Attributes:
- name: The name of the panel (required, used as a DOM identifier).
- label: The label of the panel (required, human readable).
- icon: The icon of the panel (optional, must be a valid icon identifier).
- content: The content of the panel (optional, raw HTML).
- context: Optional context data (dict / JSON) which will be passed to the front-end rendering function
- source: The source of the panel (optional, path to a JavaScript file).
- """
-
- name: str
- label: str
- icon: str
- content: str
- context: dict
- source: str
-
-
-FeatureType = Literal['template_editor', 'template_preview']
+# List of supported feature types
+FeatureType = Literal[
+ 'dashboard', # Custom dashboard items
+ 'panel', # Custom panels
+ 'template_editor', # Custom template editor
+ 'template_preview', # Custom template preview
+]
class UIFeature(TypedDict):
"""Base type definition for a ui feature.
Attributes:
+ key: The key of the feature (required, must be a unique identifier)
+ title: The title of the feature (required, human readable)
+ description: The long-form description of the feature (optional, human readable)
+ icon: The icon of the feature (optional, must be a valid icon identifier)
feature_type: The feature type (required, see documentation for all available types)
options: Feature options (required, see documentation for all available options for each type)
- source: The source of the feature (required, path to a JavaScript file).
+ context: Additional context data to be passed to the rendering function (optional, dict)
+ source: The source of the feature (required, path to a JavaScript file, with optional function name).
"""
+ key: str
+ title: str
+ description: str
+ icon: str
feature_type: FeatureType
options: dict
+ context: dict
source: str
+class CustomPanelOptions(TypedDict):
+ """Options type definition for a custom panel.
+
+ Attributes:
+ icon: The icon of the panel (optional, must be a valid icon identifier).
+ """
+
+
+class CustomDashboardItemOptions(TypedDict):
+ """Options type definition for a custom dashboard item.
+
+ Attributes:
+ width: The minimum width of the dashboard item (integer, defaults to 2)
+ height: The minimum height of the dashboard item (integer, defaults to 2)
+ """
+
+ width: int
+ height: int
+
+
class UserInterfaceMixin:
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
@@ -65,48 +81,85 @@ class UserInterfaceMixin:
super().__init__()
self.add_mixin('ui', True, __class__) # type: ignore
- def get_ui_panels(
- self, instance_type: str, instance_id: int, request: Request, **kwargs
- ) -> list[CustomPanel]:
- """Return a list of custom panels to be injected into the UI.
-
- Args:
- instance_type: The type of object being viewed (e.g. 'part')
- instance_id: The ID of the object being viewed (e.g. 123)
- request: HTTPRequest object (including user information)
-
- Returns:
- list: A list of custom panels to be injected into the UI
-
- - The returned list should contain a dict for each custom panel to be injected into the UI:
- - The following keys can be specified:
- {
- 'name': 'panel_name', # The name of the panel (required, must be unique)
- 'label': 'Panel Title', # The title of the panel (required, human readable)
- 'icon': 'icon-name', # Icon name (optional, must be a valid icon identifier)
- 'content': '
Panel content
', # HTML content to be rendered in the panel (optional)
- 'context': {'key': 'value'}, # Context data to be passed to the front-end rendering function (optional)
- 'source': 'static/plugin/panel.js', # Path to a JavaScript file to be loaded (optional)
- }
-
- - Either 'source' or 'content' must be provided
-
- """
- # Default implementation returns an empty list
- return []
-
def get_ui_features(
- self, feature_type: FeatureType, context: dict, request: Request
+ self, feature_type: FeatureType, context: dict, request: Request, **kwargs
) -> list[UIFeature]:
"""Return a list of custom features to be injected into the UI.
Arguments:
feature_type: The type of feature being requested
- context: Additional context data provided by the UI
+ context: Additional context data provided by the UI (query parameters)
request: HTTPRequest object (including user information)
Returns:
list: A list of custom UIFeature dicts to be injected into the UI
+
+ """
+ feature_map = {
+ 'dashboard': self.get_ui_dashboard_items,
+ 'panel': self.get_ui_panels,
+ 'template_editor': self.get_ui_template_editors,
+ 'template_preview': self.get_ui_template_previews,
+ }
+
+ if feature_type in feature_map:
+ return feature_map[feature_type](request, context, **kwargs)
+ else:
+ logger.warning(f'Invalid feature type: {feature_type}')
+ return []
+
+ def get_ui_panels(
+ self, request: Request, context: dict, **kwargs
+ ) -> list[UIFeature]:
+ """Return a list of custom panels to be injected into the UI.
+
+ Args:
+ request: HTTPRequest object (including user information)
+
+ Returns:
+ list: A list of custom panels to be injected into the UI
+ """
+ # Default implementation returns an empty list
+ return []
+
+ def get_ui_dashboard_items(
+ self, request: Request, context: dict, **kwargs
+ ) -> list[UIFeature]:
+ """Return a list of custom dashboard items to be injected into the UI.
+
+ Args:
+ request: HTTPRequest object (including user information)
+
+ Returns:
+ list: A list of custom dashboard items to be injected into the UI
+ """
+ # Default implementation returns an empty list
+ return []
+
+ def get_ui_template_editors(
+ self, request: Request, context: dict, **kwargs
+ ) -> list[UIFeature]:
+ """Return a list of custom template editors to be injected into the UI.
+
+ Args:
+ request: HTTPRequest object (including user information)
+
+ Returns:
+ list: A list of custom template editors to be injected into the UI
+ """
+ # Default implementation returns an empty list
+ return []
+
+ def get_ui_template_previews(
+ self, request: Request, context: dict, **kwargs
+ ) -> list[UIFeature]:
+ """Return a list of custom template previews to be injected into the UI.
+
+ Args:
+ request: HTTPRequest object (including user information)
+
+ Returns:
+ list: A list of custom template previews to be injected into the UI
"""
# Default implementation returns an empty list
return []
diff --git a/src/backend/InvenTree/plugin/base/ui/serializers.py b/src/backend/InvenTree/plugin/base/ui/serializers.py
index fdfbc67148..99a3126399 100644
--- a/src/backend/InvenTree/plugin/base/ui/serializers.py
+++ b/src/backend/InvenTree/plugin/base/ui/serializers.py
@@ -5,68 +5,60 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
-class PluginPanelSerializer(serializers.Serializer):
- """Serializer for a plugin panel."""
-
- class Meta:
- """Meta for serializer."""
-
- fields = [
- 'plugin',
- 'name',
- 'label',
- # Following fields are optional
- 'icon',
- 'content',
- 'context',
- 'source',
- ]
-
- # Required fields
- plugin = serializers.CharField(
- label=_('Plugin Key'), required=True, allow_blank=False
- )
-
- name = serializers.CharField(
- label=_('Panel Name'), required=True, allow_blank=False
- )
-
- label = serializers.CharField(
- label=_('Panel Title'), required=True, allow_blank=False
- )
-
- # Optional fields
- icon = serializers.CharField(
- label=_('Panel Icon'), required=False, allow_blank=True
- )
-
- content = serializers.CharField(
- label=_('Panel Content (HTML)'), required=False, allow_blank=True
- )
-
- context = serializers.JSONField(
- label=_('Panel Context (JSON)'), required=False, allow_null=True, default=None
- )
-
- source = serializers.CharField(
- label=_('Panel Source (javascript)'), required=False, allow_blank=True
- )
-
-
class PluginUIFeatureSerializer(serializers.Serializer):
"""Serializer for a plugin ui feature."""
class Meta:
"""Meta for serializer."""
- fields = ['feature_type', 'options', 'source']
+ fields = [
+ 'plugin_name',
+ 'feature_type',
+ 'key',
+ 'title',
+ 'description',
+ 'icon',
+ 'options',
+ 'context',
+ 'source',
+ ]
# Required fields
+
+ # The name of the plugin that provides this feature
+ plugin_name = serializers.CharField(
+ label=_('Plugin Name'), required=True, allow_blank=False
+ )
+
feature_type = serializers.CharField(
label=_('Feature Type'), required=True, allow_blank=False
)
- options = serializers.DictField(label=_('Feature Options'), required=True)
+ # Item key to be used in the UI - this should be a DOM identifier and is not user facing
+ key = serializers.CharField(
+ label=_('Feature Label'), required=True, allow_blank=False
+ )
+
+ # Title to be used in the UI - this is user facing (and should be human readable)
+ title = serializers.CharField(
+ label=_('Feature Title'), required=False, allow_blank=True
+ )
+
+ # Long-form description of the feature (optional)
+ description = serializers.CharField(
+ label=_('Feature Description'), required=False, allow_blank=True
+ )
+
+ # Optional icon
+ icon = serializers.CharField(
+ label=_('Feature Icon'), required=False, allow_blank=True
+ )
+
+ # Additional options, specific to the particular UI feature
+ options = serializers.DictField(label=_('Feature Options'), default=None)
+
+ # Server side context, supplied to the client side for rendering
+ context = serializers.DictField(label=_('Feature Context'), default=None)
source = serializers.CharField(
label=_('Feature Source (javascript)'), required=True, allow_blank=False
diff --git a/src/backend/InvenTree/plugin/base/ui/tests.py b/src/backend/InvenTree/plugin/base/ui/tests.py
index 0a5dc0af23..feda515928 100644
--- a/src/backend/InvenTree/plugin/base/ui/tests.py
+++ b/src/backend/InvenTree/plugin/base/ui/tests.py
@@ -33,7 +33,60 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
plugins = registry.with_mixin('ui')
self.assertGreater(len(plugins), 0)
- def test_panels(self):
+ def test_ui_dashboard_items(self):
+ """Test that the sample UI plugin provides custom dashboard items."""
+ # Ensure the user has superuser status
+ self.user.is_superuser = True
+ self.user.save()
+
+ url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'dashboard'})
+
+ response = self.get(url)
+ self.assertEqual(len(response.data), 4)
+
+ for item in response.data:
+ self.assertEqual(item['plugin_name'], 'sampleui')
+
+ self.assertEqual(response.data[0]['key'], 'broken-dashboard-item')
+ self.assertEqual(response.data[0]['title'], 'Broken Dashboard Item')
+ self.assertEqual(response.data[0]['source'], '/this/does/not/exist.js')
+
+ self.assertEqual(response.data[1]['key'], 'sample-dashboard-item')
+ self.assertEqual(response.data[1]['title'], 'Sample Dashboard Item')
+ self.assertEqual(
+ response.data[1]['source'],
+ '/static/plugins/sampleui/sample_dashboard_item.js',
+ )
+
+ self.assertEqual(response.data[2]['key'], 'dynamic-dashboard-item')
+ self.assertEqual(response.data[2]['title'], 'Context Dashboard Item')
+ self.assertEqual(
+ response.data[2]['source'],
+ '/static/plugins/sampleui/sample_dashboard_item.js:renderContextItem',
+ )
+
+ self.assertEqual(response.data[3]['key'], 'admin-dashboard-item')
+ self.assertEqual(response.data[3]['title'], 'Admin Dashboard Item')
+ self.assertEqual(
+ response.data[3]['source'],
+ '/static/plugins/sampleui/admin_dashboard_item.js',
+ )
+
+ # Additional options and context data should be passed through to the client
+ self.assertDictEqual(response.data[3]['options'], {'width': 4, 'height': 2})
+
+ self.assertDictEqual(
+ response.data[3]['context'], {'secret-key': 'this-is-a-secret'}
+ )
+
+ # Remove superuser status - the 'admin-dashboard-item' should disappear
+ self.user.is_superuser = False
+ self.user.save()
+
+ response = self.get(url)
+ self.assertEqual(len(response.data), 3)
+
+ def test_ui_panels(self):
"""Test that the sample UI plugin provides custom panels."""
from part.models import Part
@@ -45,7 +98,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
_part.active = True
_part.save()
- url = reverse('api-plugin-panel-list')
+ url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'panel'})
query_data = {'target_model': 'part', 'target_id': _part.pk}
@@ -59,7 +112,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default
- self.assertEqual(4, len(response.data))
+ self.assertEqual(3, len(response.data))
_part.active = False
_part.save()
@@ -74,23 +127,27 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data)
- # There should still be 3 panels
- self.assertEqual(3, len(response.data))
+ # There should still be 2 panels
+ self.assertEqual(2, len(response.data))
- # Check for the correct panel names
- self.assertEqual(response.data[0]['name'], 'sample_panel')
- self.assertIn('content', response.data[0])
- self.assertNotIn('source', response.data[0])
+ for panel in response.data:
+ self.assertEqual(panel['plugin_name'], 'sampleui')
+ self.assertEqual(panel['feature_type'], 'panel')
- self.assertEqual(response.data[1]['name'], 'broken_panel')
- self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
- self.assertNotIn('content', response.data[1])
+ self.assertEqual(response.data[0]['key'], 'broken-panel')
+ self.assertEqual(response.data[0]['title'], 'Broken Panel')
+ self.assertEqual(response.data[0]['source'], '/this/does/not/exist.js')
- self.assertEqual(response.data[2]['name'], 'dynamic_panel')
+ self.assertEqual(response.data[1]['key'], 'dynamic-panel')
+ self.assertEqual(response.data[1]['title'], 'Dynamic Panel')
self.assertEqual(
- response.data[2]['source'], '/static/plugins/sampleui/sample_panel.js'
+ response.data[1]['source'], '/static/plugins/sampleui/sample_panel.js'
)
- self.assertNotIn('content', response.data[2])
+
+ ctx = response.data[1]['context']
+
+ for k in ['version', 'plugin_version', 'random', 'time']:
+ self.assertIn(k, ctx)
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(
@@ -105,8 +162,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
# Set the setting back to True for subsequent tests
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
- def test_ui_features(self):
- """Test that the sample UI plugin provides custom features."""
+ def test_ui_template_editors(self):
+ """Test that the sample UI plugin provides template editor features."""
template_editor_url = reverse(
'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'}
)
@@ -120,30 +177,39 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
'template_model': 'part',
}
- # Request custom template editor information
+ # Request custom label template editor information
response = self.get(template_editor_url, data=query_data_label)
self.assertEqual(1, len(response.data))
+ data = response.data[0]
+
+ for k, v in {
+ 'plugin_name': 'sampleui',
+ 'key': 'sample-template-editor',
+ 'title': 'Sample Template Editor',
+ 'source': '/static/plugins/sampleui/sample_template.js:getTemplateEditor',
+ }.items():
+ self.assertEqual(data[k], v)
+
+ # Request custom report template editor information
response = self.get(template_editor_url, data=query_data_report)
self.assertEqual(0, len(response.data))
+ # Request custom report template preview information
response = self.get(template_preview_url, data=query_data_report)
self.assertEqual(1, len(response.data))
- # Check for the correct feature details here
- self.assertEqual(response.data[0]['feature_type'], 'template_preview')
- self.assertDictEqual(
- response.data[0]['options'],
- {
- 'key': 'sample-template-preview',
- 'title': 'Sample Template Preview',
- 'icon': 'category',
- },
- )
- self.assertEqual(
- response.data[0]['source'],
- '/static/plugin/sample_template.js:getTemplatePreview',
- )
+ data = response.data[0]
+
+ for k, v in {
+ 'plugin_name': 'sampleui',
+ 'feature_type': 'template_preview',
+ 'key': 'sample-template-preview',
+ 'title': 'Sample Template Preview',
+ 'context': None,
+ 'source': '/static/plugins/sampleui/sample_preview.js:getTemplatePreview',
+ }.items():
+ self.assertEqual(data[k], v)
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(
diff --git a/src/backend/InvenTree/plugin/samples/integration/templates/uidemo/custom_part_panel.html b/src/backend/InvenTree/plugin/samples/integration/templates/uidemo/custom_part_panel.html
deleted file mode 100644
index 72815ed67e..0000000000
--- a/src/backend/InvenTree/plugin/samples/integration/templates/uidemo/custom_part_panel.html
+++ /dev/null
@@ -1,30 +0,0 @@
-{% load i18n %}
-
-
Custom Plugin Panel
-
-
-This content has been rendered by a custom plugin, and will be displayed for any "part" instance
-(as long as the plugin is enabled).
-This content has been rendered on the server, using the django templating system.
-
diff --git a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
index fdb1dc57ec..04d757c790 100644
--- a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
+++ b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
@@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
from InvenTree.version import INVENTREE_SW_VERSION
from part.models import Part
from plugin import InvenTreePlugin
-from plugin.helpers import render_template, render_text
from plugin.mixins import SettingsMixin, UserInterfaceMixin
@@ -19,7 +18,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
SLUG = 'sampleui'
TITLE = 'Sample User Interface Plugin'
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
- VERSION = '1.1'
+ VERSION = '2.0'
ADMIN_SOURCE = 'ui_settings.js'
@@ -50,39 +49,22 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
},
}
- def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs):
+ def get_ui_panels(self, request, context, **kwargs):
"""Return a list of custom panels to be injected into the UI."""
panels = []
+ context = context or {}
# First, add a custom panel which will appear on every type of page
# This panel will contain a simple message
- content = render_text(
- """
- This is a sample panel which appears on every page.
- It renders a simple string of HTML content.
-
-
-
Instance Details:
-
-
Instance Type: {{ instance_type }}
-
Instance ID: {{ instance_id }}
-
- """,
- context={'instance_type': instance_type, 'instance_id': instance_id},
- )
-
- panels.append({
- 'name': 'sample_panel',
- 'label': 'Sample Panel',
- 'content': content,
- })
+ target_model = context.get('target_model', None)
+ target_id = context.get('target_id', None)
# A broken panel which tries to load a non-existent JS file
- if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
+ if target_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
panels.append({
- 'name': 'broken_panel',
- 'label': 'Broken Panel',
+ 'key': 'broken-panel',
+ 'title': 'Broken Panel',
'source': '/this/does/not/exist.js',
})
@@ -90,85 +72,131 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
# Note that we additionally provide some "context" data to the front-end render function
if self.get_setting('ENABLE_DYNAMIC_PANEL'):
panels.append({
- 'name': 'dynamic_panel',
- 'label': 'Dynamic Part Panel',
+ 'key': 'dynamic-panel',
+ 'title': 'Dynamic Panel',
'source': self.plugin_static_file('sample_panel.js'),
+ 'icon': 'part',
'context': {
'version': INVENTREE_SW_VERSION,
'plugin_version': self.VERSION,
'random': random.randint(1, 100),
'time': time.time(),
},
- 'icon': 'part',
})
# Next, add a custom panel which will appear on the 'part' page
# Note that this content is rendered from a template file,
# using the django templating system
- if self.get_setting('ENABLE_PART_PANELS') and instance_type == 'part':
+ if self.get_setting('ENABLE_PART_PANELS') and target_model == 'part':
try:
- part = Part.objects.get(pk=instance_id)
+ part = Part.objects.get(pk=target_id)
except (Part.DoesNotExist, ValueError):
part = None
- # Note: This panel will *only* be available if the part is active
- if part and part.active:
- content = render_template(
- self, 'uidemo/custom_part_panel.html', context={'part': part}
- )
-
- panels.append({
- 'name': 'part_panel',
- 'label': 'Part Panel',
- 'content': content,
- })
+ panels.append({
+ 'key': 'part-panel',
+ 'title': _('Part Panel'),
+ 'source': self.plugin_static_file('sample_panel.js:renderPartPanel'),
+ 'icon': 'part',
+ 'context': {'part_name': part.name if part else ''},
+ })
# Next, add a custom panel which will appear on the 'purchaseorder' page
- if (
- self.get_setting('ENABLE_PURCHASE_ORDER_PANELS')
- and instance_type == 'purchaseorder'
+ if target_model == 'purchaseorder' and self.get_setting(
+ 'ENABLE_PURCHASE_ORDER_PANELS'
):
panels.append({
- 'name': 'purchase_order_panel',
- 'label': 'Purchase Order Panel',
- 'content': 'This is a custom panel which appears on the Purchase Order view page.',
+ 'key': 'purchase_order_panel',
+ 'title': 'Purchase Order Panel',
+ 'source': self.plugin_static_file('sample_panel.js:renderPoPanel'),
+ })
+
+ # Admin panel - only visible to admin users
+ if request.user.is_superuser:
+ panels.append({
+ 'key': 'admin-panel',
+ 'title': 'Admin Panel',
+ 'source': self.plugin_static_file(
+ 'sample_panel.js:renderAdminOnlyPanel'
+ ),
})
return panels
- def get_ui_features(self, feature_type, context, request):
- """Return a list of custom features to be injected into the UI."""
- if (
- feature_type == 'template_editor'
- and context.get('template_type') == 'labeltemplate'
- ):
- return [
- {
- 'feature_type': 'template_editor',
- 'options': {
- 'key': 'sample-template-editor',
- 'title': 'Sample Template Editor',
- 'icon': 'keywords',
- },
- 'source': '/static/plugin/sample_template.js:getTemplateEditor',
- }
- ]
+ def get_ui_dashboard_items(self, request, context, **kwargs):
+ """Return a list of custom dashboard items."""
+ items = [
+ {
+ 'key': 'broken-dashboard-item',
+ 'title': _('Broken Dashboard Item'),
+ 'description': _(
+ 'This is a broken dashboard item - it will not render!'
+ ),
+ 'source': '/this/does/not/exist.js',
+ },
+ {
+ 'key': 'sample-dashboard-item',
+ 'title': _('Sample Dashboard Item'),
+ 'description': _(
+ 'This is a sample dashboard item. It renders a simple string of HTML content.'
+ ),
+ 'source': self.plugin_static_file('sample_dashboard_item.js'),
+ },
+ {
+ 'key': 'dynamic-dashboard-item',
+ 'title': _('Context Dashboard Item'),
+ 'description': 'A dashboard item which passes context data from the server',
+ 'source': self.plugin_static_file(
+ 'sample_dashboard_item.js:renderContextItem'
+ ),
+ 'context': {'foo': 'bar', 'hello': 'world'},
+ 'options': {'width': 3, 'height': 2},
+ },
+ ]
- if feature_type == 'template_preview':
+ # Admin item - only visible to users with superuser access
+ if request.user.is_superuser:
+ items.append({
+ 'key': 'admin-dashboard-item',
+ 'title': _('Admin Dashboard Item'),
+ 'description': _('This is an admin-only dashboard item.'),
+ 'source': self.plugin_static_file('admin_dashboard_item.js'),
+ 'options': {'width': 4, 'height': 2},
+ 'context': {'secret-key': 'this-is-a-secret'},
+ })
+
+ return items
+
+ def get_ui_template_editors(self, request, context, **kwargs):
+ """Return a list of custom template editors."""
+ # If the context is a label template, return a custom template editor
+ if context.get('template_type') == 'labeltemplate':
return [
{
- 'feature_type': 'template_preview',
- 'options': {
- 'key': 'sample-template-preview',
- 'title': 'Sample Template Preview',
- 'icon': 'category',
- },
- 'source': '/static/plugin/sample_template.js:getTemplatePreview',
+ 'key': 'sample-template-editor',
+ 'title': 'Sample Template Editor',
+ 'icon': 'keywords',
+ 'source': self.plugin_static_file(
+ 'sample_template.js:getTemplateEditor'
+ ),
}
]
return []
+ def get_ui_template_previews(self, request, context, **kwargs):
+ """Return a list of custom template previews."""
+ return [
+ {
+ 'key': 'sample-template-preview',
+ 'title': 'Sample Template Preview',
+ 'icon': 'category',
+ 'source': self.plugin_static_file(
+ 'sample_preview.js:getTemplatePreview'
+ ),
+ }
+ ]
+
def get_admin_context(self) -> dict:
"""Return custom context data which can be rendered in the admin panel."""
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/admin_dashboard_item.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/admin_dashboard_item.js
new file mode 100644
index 0000000000..8ec84f7ddb
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/admin_dashboard_item.js
@@ -0,0 +1,20 @@
+/**
+ * A sample dashboard item plugin for InvenTree.
+ *
+ * - This is a *very basic* example.
+ * - In practice, you would want to use React / Mantine / etc to render more complex UI elements.
+ */
+
+export function renderDashboardItem(target, data) {
+
+ if (!target) {
+ console.error("No target provided to renderDashboardItem");
+ return;
+ }
+
+ target.innerHTML = `
+
Admin Item
+
+
Hello there, admin user!
+ `;
+}
diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_dashboard_item.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_dashboard_item.js
new file mode 100644
index 0000000000..3c59b111e9
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_dashboard_item.js
@@ -0,0 +1,49 @@
+/**
+ * A sample dashboard item plugin for InvenTree.
+ *
+ * - This is a *very basic* example.
+ * - In practice, you would want to use React / Mantine / etc to render more complex UI elements.
+ */
+
+export function renderDashboardItem(target, data) {
+
+ if (!target) {
+ console.error("No target provided to renderDashboardItem");
+ return;
+ }
+
+ target.innerHTML = `
+
Sample Dashboard Item
+
+
Hello world! This is a sample dashboard item loaded by the plugin system.
+ `;
+}
+
+
+export function renderContextItem(target, data) {
+
+ if (!target) {
+ console.error("No target provided to renderContextItem");
+ return;
+ }
+
+ let context = data?.context ?? {};
+
+ let ctxString = '';
+
+ for (let key in context) {
+ ctxString += `
${key}
${context[key]}
`;
+ }
+
+ target.innerHTML = `
+
Sample Context Item
+
+
Hello world! This is a sample context item loaded by the plugin system.
+
+
+
Item
Value
+ ${ctxString}
+
+
+ `;
+}
diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js
index 9c382d8206..eaee2d6a37 100644
--- a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js
+++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js
@@ -43,6 +43,55 @@ export function renderPanel(target, data) {
}
+/**
+ * Render a panel on a Part detail page
+ */
+export function renderPartPanel(target, data) {
+
+ if (!target) {
+ console.error("No target provided to renderPartPanel");
+ return;
+ }
+
+ target.innerHTML = `
+
Part Detail Panel
+
+
This is a custom panel for a Part detail page
+ `;
+}
+
+
+
+/**
+ * Render a panel on a PurchaseOrder detail page
+ */
+export function renderPoPanel(target, data) {
+ if (!target) {
+ console.error("No target provided to renderPoPanel");
+ return;
+ }
+
+ target.innerHTML = `
+
Order Reference: ${data.instance?.reference}
+
+
This is a custom panel for a PurchaseOrder detail page
+ `;
+}
+
+
+/**
+ * Render a panel that is only visible to admin users
+ */
+export function renderAdminOnlyPanel(target, data) {
+ if (!target) {
+ console.error("No target provided to renderAdminOnlyPanel");
+ return;
+ }
+
+ target.innerHTML = `Hello Admin user! This panel is only visible to admin users.`;
+}
+
+
// Dynamically hide the panel based on the provided context
export function isPanelHidden(context) {
diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_preview.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_preview.js
new file mode 100644
index 0000000000..1cf763f38f
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_preview.js
@@ -0,0 +1,12 @@
+export function getTemplatePreview({ featureContext, pluginContext }) {
+ const { ref } = featureContext;
+ console.log("Template preview feature was called with", featureContext, pluginContext);
+
+ featureContext.registerHandlers({
+ updatePreview: (...args) => {
+ console.log("updatePreview", args);
+ }
+ });
+
+ ref.innerHTML = "