mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 04:25:42 +00:00
PUI Plugin Panels (#7470)
* Adds basic API endpoint for requesting plugin panels
* Split PanelType out into own file
* Placeholder for a plugin panel loaded dynamically
* Add some dummy data for the plugin panels
* Example of plugin panel selection based on page
* Expose some global window attributes
* Add new setting
* Disable panel return if plugin integration is not enabled
* Update hook to auto-magically load plugin panels
* Allow custom panel integration for more panel groups
* Remove debug call
* Tweak query return data
* async fn
* Adds <PluginPanel> component for handling panel render
* Cleanup
* Prevent API requests before instance ID is known
* Pass instance data through
* Framework for a sample plugin which implements custom panels
* offload custom panels to sample plugin
* Load raw HTML content
* Expand custom panel rendering demo
* Adjust API endpoints
* Add function to clear out static files which do not match installed plugin(s)
* Update static files when installing plugins from file
* Update static files when installing or uninstalling a plugin
* Update static files on config change
* Pass more information through to plugin panels
* Prepend hostname to plugin source
* Pass instance detail through
* Cleanup code for passing data through to plugin panels
- Define interface type
- Shorten variable names
* Update docs requirements
* Revert "Update docs requirements"
This reverts commit 63a06d97f5
.
* Add placeholder for documentation
* Fix imports
* Add a broken panel which tries to load a non-existent javascript file
* Render error message if plugin does not load correctly
* Only allow superuser to perform plugin actions
* Code cleanup
* Add "dynamic" contnt - javascript file - to example plugin
* Remove default values
* Cleanup unused code
* PanelGroup updates
* Cleanup hooks for changing panel state
* More work needed...
* Code cleanup
* More updates / refactoring
- Allow dynamic hiding of a particular panel
- Pass target ref as positional argument
- Better handling of async calls
* Documentation
* Bump API version
* Provide theme object to plugin context
* Adjust sample plugin
* Docs updates
* Fix includefile call in docs
* Improve type annotation
* Cleanup
* Enable plugin panels for "purchasing index" and "sales index" pages
* Fix for plugin query check
* Improvements to panel selection
- Code refactor / cleanup
- Ensure that a valid panel is always displayed
- Allow plugin panels to persist, even after reload
* Playwright test fixes
* Update src/frontend/src/hooks/UsePluginPanels.tsx
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
* Update src/frontend/src/components/plugins/PluginPanel.tsx
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
* Update src/frontend/src/components/plugins/PluginContext.tsx
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
* Fix context
* Add more context data
* Docs updates
* Reimplement local state
* Fix mkdocs.yml
* Expose 'colorScheme' to plugin context
* Define CustomPanel type definition
* Add unit testing for user interface plugins
* Add front-end tests for plugin panels
* Add new setting to plugin_settings_keys
* Adds helper function for annotating build line allocations
* Improve query efficiency
- Especially around unit testing
- Ensure all settings are generated
- Do not auto-create settings during registry load
* Improve query efficiency for build order operations
* Reduce max query count for specific test
* Revert query count limit
* playwright test updates
---------
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
This commit is contained in:
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 253
|
||||
INVENTREE_API_VERSION = 254
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v254 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7470
|
||||
- Implements new API endpoints for enabling custom UI functionality via plugins
|
||||
|
||||
v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
|
||||
- Adjustments for user API endpoints
|
||||
|
||||
|
@ -8,6 +8,7 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Run the management command."""
|
||||
from plugin.staticfiles import collect_plugins_static_files
|
||||
import plugin.staticfiles
|
||||
|
||||
collect_plugins_static_files()
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
plugin.staticfiles.clear_plugins_static_files()
|
||||
|
@ -439,7 +439,7 @@ class ReferenceIndexingMixin(models.Model):
|
||||
)
|
||||
|
||||
# Check that the reference field can be rebuild
|
||||
cls.rebuild_reference_field(value, validate=True)
|
||||
return cls.rebuild_reference_field(value, validate=True)
|
||||
|
||||
@classmethod
|
||||
def rebuild_reference_field(cls, reference, validate=False):
|
||||
|
@ -133,6 +133,34 @@ STATIC_URL = '/static/'
|
||||
# Web URL endpoint for served media files
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# Are plugins enabled?
|
||||
PLUGINS_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
|
||||
)
|
||||
|
||||
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
|
||||
)
|
||||
|
||||
PLUGIN_FILE = config.get_plugin_file()
|
||||
|
||||
# Plugin test settings
|
||||
PLUGIN_TESTING = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING
|
||||
) # Are plugins being tested?
|
||||
|
||||
PLUGIN_TESTING_SETUP = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False
|
||||
) # Load plugins from setup hooks in testing?
|
||||
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
|
||||
PLUGIN_RETRY = get_setting(
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
|
||||
) # How often should plugin loading be tried?
|
||||
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
STATICFILES_DIRS = []
|
||||
|
||||
# Translated Template settings
|
||||
@ -153,6 +181,12 @@ if DEBUG and 'collectstatic' not in sys.argv:
|
||||
if web_dir.exists():
|
||||
STATICFILES_DIRS.append(web_dir)
|
||||
|
||||
# Append directory for sample plugin static content (if in debug mode)
|
||||
if PLUGINS_ENABLED:
|
||||
print('Adding plugin sample static content')
|
||||
STATICFILES_DIRS.append(BASE_DIR.joinpath('plugin', 'samples', 'static'))
|
||||
|
||||
print('-', STATICFILES_DIRS[-1])
|
||||
STATFILES_I18_PROCESSORS = ['InvenTree.context.status_codes']
|
||||
|
||||
# Color Themes Directory
|
||||
@ -1254,29 +1288,6 @@ IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied]
|
||||
MAINTENANCE_MODE_RETRY_AFTER = 10
|
||||
MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBackend'
|
||||
|
||||
# Are plugins enabled?
|
||||
PLUGINS_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
|
||||
)
|
||||
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
|
||||
)
|
||||
|
||||
PLUGIN_FILE = config.get_plugin_file()
|
||||
|
||||
# Plugin test settings
|
||||
PLUGIN_TESTING = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING
|
||||
) # Are plugins being tested?
|
||||
PLUGIN_TESTING_SETUP = get_setting(
|
||||
'INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False
|
||||
) # Load plugins from setup hooks in testing?
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
PLUGIN_RETRY = get_setting(
|
||||
'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int
|
||||
) # How often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
# Flag to allow table events during testing
|
||||
TESTING_TABLE_EVENTS = False
|
||||
|
||||
|
@ -291,6 +291,18 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
|
||||
self.assertLess(n, value, msg=msg)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Setup for API tests.
|
||||
|
||||
- Ensure that all global settings are assigned default values.
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
InvenTreeSetting.build_default_values()
|
||||
|
||||
super().setUpTestData()
|
||||
|
||||
def check_response(self, url, response, expected_code=None):
|
||||
"""Debug output for an unexpected response."""
|
||||
# Check that the response returned the expected status code
|
||||
|
@ -484,7 +484,7 @@ if settings.ENABLE_PLATFORM_FRONTEND:
|
||||
|
||||
urlpatterns += frontendpatterns
|
||||
|
||||
# Append custom plugin URLs (if plugin support is enabled)
|
||||
# Append custom plugin URLs (if custom plugin support is enabled)
|
||||
if settings.PLUGINS_ENABLED:
|
||||
urlpatterns.append(get_plugin_urls())
|
||||
|
||||
|
25
src/backend/InvenTree/build/filters.py
Normal file
25
src/backend/InvenTree/build/filters.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Queryset filtering helper functions for the Build app."""
|
||||
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
|
||||
def annotate_allocated_quantity(queryset: Q) -> Q:
|
||||
"""
|
||||
Annotate the 'allocated' quantity for each build item in the queryset.
|
||||
|
||||
Arguments:
|
||||
queryset: The BuildLine queryset to annotate
|
||||
|
||||
"""
|
||||
|
||||
queryset = queryset.prefetch_related('allocations')
|
||||
|
||||
return queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0,
|
||||
output_field=models.DecimalField()
|
||||
)
|
||||
)
|
@ -9,7 +9,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models import F, Sum, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch.dispatcher import receiver
|
||||
@ -24,6 +24,7 @@ from rest_framework import serializers
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from stock.status_codes import StockStatus, StockHistoryCode
|
||||
|
||||
from build.filters import annotate_allocated_quantity
|
||||
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||
from generic.states import StateTransitionMixin
|
||||
|
||||
@ -124,8 +125,7 @@ class Build(
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom save method for the BuildOrder model"""
|
||||
self.validate_reference_field(self.reference)
|
||||
self.reference_int = self.rebuild_reference_field(self.reference)
|
||||
self.reference_int = self.validate_reference_field(self.reference)
|
||||
|
||||
# Check part when initially creating the build order
|
||||
if not self.pk or self.has_field_changed('part'):
|
||||
@ -987,12 +987,13 @@ class Build(
|
||||
items_to_save = []
|
||||
items_to_delete = []
|
||||
|
||||
lines = self.untracked_line_items
|
||||
lines = lines.prefetch_related('allocations')
|
||||
lines = self.untracked_line_items.all()
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
for build_line in lines:
|
||||
|
||||
reduce_by = build_line.allocated_quantity() - build_line.quantity
|
||||
reduce_by = build_line.allocated - build_line.quantity
|
||||
|
||||
if reduce_by <= 0:
|
||||
continue
|
||||
@ -1290,18 +1291,20 @@ class Build(
|
||||
"""Returns a list of BuildLine objects which have not been fully allocated."""
|
||||
lines = self.build_lines.all()
|
||||
|
||||
# Remove any 'consumable' line items
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
|
||||
if tracked is True:
|
||||
lines = lines.filter(bom_item__sub_part__trackable=True)
|
||||
elif tracked is False:
|
||||
lines = lines.filter(bom_item__sub_part__trackable=False)
|
||||
|
||||
unallocated_lines = []
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
for line in lines:
|
||||
if not line.is_fully_allocated():
|
||||
unallocated_lines.append(line)
|
||||
# Filter out any lines which have been fully allocated
|
||||
lines = lines.filter(allocated__lt=F('quantity'))
|
||||
|
||||
return unallocated_lines
|
||||
return lines
|
||||
|
||||
def is_fully_allocated(self, tracked=None):
|
||||
"""Test if the BuildOrder has been fully allocated.
|
||||
@ -1314,19 +1317,24 @@ class Build(
|
||||
Returns:
|
||||
True if the BuildOrder has been fully allocated, otherwise False
|
||||
"""
|
||||
lines = self.unallocated_lines(tracked=tracked)
|
||||
return len(lines) == 0
|
||||
|
||||
return self.unallocated_lines(tracked=tracked).count() == 0
|
||||
|
||||
def is_output_fully_allocated(self, output):
|
||||
"""Determine if the specified output (StockItem) has been fully allocated for this build
|
||||
|
||||
Args:
|
||||
output: StockItem object
|
||||
output: StockItem object (the "in production" output to test against)
|
||||
|
||||
To determine if the output has been fully allocated,
|
||||
we need to test all "trackable" BuildLine objects
|
||||
"""
|
||||
for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
|
||||
|
||||
lines = self.build_lines.filter(bom_item__sub_part__trackable=True)
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
|
||||
# Find any lines which have not been fully allocated
|
||||
for line in lines:
|
||||
# Grab all BuildItem objects which point to this output
|
||||
allocations = BuildItem.objects.filter(
|
||||
build_line=line,
|
||||
@ -1350,11 +1358,14 @@ class Build(
|
||||
Returns:
|
||||
True if any BuildLine has been over-allocated.
|
||||
"""
|
||||
for line in self.build_lines.all():
|
||||
if line.is_overallocated():
|
||||
return True
|
||||
|
||||
return False
|
||||
lines = self.build_lines.all().exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
# Find any lines which have been over-allocated
|
||||
lines = lines.filter(allocated__gt=F('quantity'))
|
||||
|
||||
return lines.count() > 0
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
@ -1692,6 +1703,9 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||
- If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
|
||||
|
||||
TODO: This is quite expensive (in terms of number of database hits) - and requires some thought
|
||||
|
||||
"""
|
||||
item = self.stock_item
|
||||
|
||||
|
@ -2095,6 +2095,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'after_save': reload_plugin_registry,
|
||||
},
|
||||
'ENABLE_PLUGINS_INTERFACE': {
|
||||
'name': _('Enable interface integration'),
|
||||
'description': _('Enable plugins to integrate into the user interface'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
'after_save': reload_plugin_registry,
|
||||
},
|
||||
'PROJECT_CODES_ENABLED': {
|
||||
'name': _('Enable project codes'),
|
||||
'description': _('Enable project codes for tracking projects'),
|
||||
|
@ -11,11 +11,13 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import plugin.serializers as PluginSerializers
|
||||
from common.api import GlobalSettingsPermissions
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.api import MetadataView
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import (
|
||||
@ -414,6 +416,38 @@ class RegistryStatusView(APIView):
|
||||
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):
|
||||
# Allow plugins to fill this data out
|
||||
plugin_panels = _plugin.get_custom_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)
|
||||
|
||||
return Response(PluginSerializers.PluginPanelSerializer(panels, many=True).data)
|
||||
|
||||
|
||||
class PluginMetadataView(MetadataView):
|
||||
"""Metadata API endpoint for the PluginConfig model."""
|
||||
|
||||
@ -428,6 +462,21 @@ plugin_api_urls = [
|
||||
path(
|
||||
'plugins/',
|
||||
include([
|
||||
path(
|
||||
'ui/',
|
||||
include([
|
||||
path(
|
||||
'panels/',
|
||||
include([
|
||||
path(
|
||||
'',
|
||||
PluginPanelList.as_view(),
|
||||
name='api-plugin-panel-list',
|
||||
)
|
||||
]),
|
||||
)
|
||||
]),
|
||||
),
|
||||
# Plugin management
|
||||
path('reload/', PluginReload.as_view(), name='api-plugin-reload'),
|
||||
path('install/', PluginInstall.as_view(), name='api-plugin-install'),
|
||||
|
@ -0,0 +1,76 @@
|
||||
"""UserInterfaceMixin class definition.
|
||||
|
||||
Allows integration of custom UI elements into the React user interface.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
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).
|
||||
source: The source of the panel (optional, path to a JavaScript file).
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
icon: str
|
||||
content: str
|
||||
source: str
|
||||
|
||||
|
||||
class UserInterfaceMixin:
|
||||
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
|
||||
|
||||
- All content is accessed via the API, as requested by the user interface.
|
||||
- This means that content can be dynamically generated, based on the current state of the system.
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
"""Metaclass for this plugin mixin."""
|
||||
|
||||
MIXIN_NAME = 'ui'
|
||||
|
||||
def __init__(self):
|
||||
"""Register mixin."""
|
||||
super().__init__()
|
||||
self.add_mixin('ui', True, __class__)
|
||||
|
||||
def get_custom_panels(
|
||||
self, instance_type: str, instance_id: int, request: Request
|
||||
) -> list[CustomPanel]:
|
||||
"""Return a list of custom panels to be injected into the UI.
|
||||
|
||||
Args:
|
||||
instance_type: The type of object being viewed (e.g. 'part')
|
||||
instance_id: The ID of the object being viewed (e.g. 123)
|
||||
request: HTTPRequest object (including user information)
|
||||
|
||||
Returns:
|
||||
list: A list of custom panels to be injected into the UI
|
||||
|
||||
- The returned list should contain a dict for each custom panel to be injected into the UI:
|
||||
- The following keys can be specified:
|
||||
{
|
||||
'name': 'panel_name', # The name of the panel (required, must be unique)
|
||||
'label': 'Panel Title', # The title of the panel (required, human readable)
|
||||
'icon': 'icon-name', # Icon name (optional, must be a valid icon identifier)
|
||||
'content': '<p>Panel content</p>', # HTML content to be rendered in the panel (optional)
|
||||
'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 []
|
@ -8,7 +8,8 @@ from django.urls import include, path, re_path, reverse
|
||||
|
||||
from error_report.models import Error
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.base.integration.PanelMixin import PanelMixin
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
@ -341,7 +342,10 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
|
||||
class PanelMixinTests(InvenTreeTestCase):
|
||||
"""Test that the PanelMixin plugin operates correctly."""
|
||||
"""Test that the PanelMixin plugin operates correctly.
|
||||
|
||||
TODO: This class will be removed in the future, as the PanelMixin is deprecated.
|
||||
"""
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
@ -475,3 +479,100 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
|
||||
plugin = Wrong()
|
||||
plugin.get_custom_panels('abc', 'abc')
|
||||
|
||||
|
||||
class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
"""Test the UserInterfaceMixin plugin mixin class."""
|
||||
|
||||
roles = 'all'
|
||||
|
||||
fixtures = ['part', 'category', 'location', 'stock']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Set up the test case."""
|
||||
super().setUpTestData()
|
||||
|
||||
# Ensure that the 'sampleui' plugin is installed and active
|
||||
registry.set_plugin_state('sampleui', True)
|
||||
|
||||
# Ensure that UI plugins are enabled
|
||||
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
|
||||
|
||||
def test_installed(self):
|
||||
"""Test that the sample UI plugin is installed and active."""
|
||||
plugin = registry.get_plugin('sampleui')
|
||||
self.assertTrue(plugin.is_active())
|
||||
|
||||
plugins = registry.with_mixin('ui')
|
||||
self.assertGreater(len(plugins), 0)
|
||||
|
||||
def test_panels(self):
|
||||
"""Test that the sample UI plugin provides custom panels."""
|
||||
from part.models import Part
|
||||
|
||||
plugin = registry.get_plugin('sampleui')
|
||||
|
||||
_part = Part.objects.first()
|
||||
|
||||
# Ensure that the part is active
|
||||
_part.active = True
|
||||
_part.save()
|
||||
|
||||
url = reverse('api-plugin-panel-list')
|
||||
|
||||
query_data = {'target_model': 'part', 'target_id': _part.pk}
|
||||
|
||||
# Enable *all* plugin settings
|
||||
plugin.set_setting('ENABLE_PART_PANELS', True)
|
||||
plugin.set_setting('ENABLE_PURCHASE_ORDER_PANELS', True)
|
||||
plugin.set_setting('ENABLE_BROKEN_PANELS', True)
|
||||
plugin.set_setting('ENABLE_DYNAMIC_PANEL', True)
|
||||
|
||||
# Request custom panel information for a part instance
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should be 4 active panels for the part by default
|
||||
self.assertEqual(4, len(response.data))
|
||||
|
||||
_part.active = False
|
||||
_part.save()
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# As the part is not active, only 3 panels left
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
# Disable the "ENABLE_PART_PANELS" setting, and try again
|
||||
plugin.set_setting('ENABLE_PART_PANELS', False)
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should still be 3 panels
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
# Check for the correct panel names
|
||||
self.assertEqual(response.data[0]['name'], 'sample_panel')
|
||||
self.assertIn('content', response.data[0])
|
||||
self.assertNotIn('source', response.data[0])
|
||||
|
||||
self.assertEqual(response.data[1]['name'], 'broken_panel')
|
||||
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
|
||||
self.assertNotIn('content', response.data[1])
|
||||
|
||||
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
|
||||
self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js')
|
||||
self.assertNotIn('content', response.data[2])
|
||||
|
||||
# Next, disable the global setting for UI integration
|
||||
InvenTreeSetting.set_setting(
|
||||
'ENABLE_PLUGINS_INTERFACE', False, change_user=None
|
||||
)
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should be no panels available
|
||||
self.assertEqual(0, len(response.data))
|
||||
|
||||
# Set the setting back to True for subsequent tests
|
||||
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
|
||||
|
@ -10,6 +10,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import plugin.models
|
||||
import plugin.staticfiles
|
||||
from InvenTree.exceptions import log_error
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -119,6 +120,10 @@ def install_plugins_file():
|
||||
log_error('pip')
|
||||
return False
|
||||
|
||||
# Update static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
plugin.staticfiles.clear_plugins_static_files()
|
||||
|
||||
# At this point, the plugins file has been installed
|
||||
return True
|
||||
|
||||
@ -256,6 +261,9 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
|
||||
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
# Update static files
|
||||
plugin.staticfiles.collect_plugins_static_files()
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@ -320,6 +328,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
|
||||
# Remove the plugin configuration from the database
|
||||
cfg.delete()
|
||||
|
||||
# Remove static files associated with this plugin
|
||||
plugin.staticfiles.clear_plugin_static_files(cfg.key)
|
||||
|
||||
# Reload the plugin registry
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
|
@ -14,6 +14,7 @@ from plugin.base.integration.ReportMixin import ReportMixin
|
||||
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||
from plugin.base.integration.UrlsMixin import UrlsMixin
|
||||
from plugin.base.integration.UserInterfaceMixin import UserInterfaceMixin
|
||||
from plugin.base.integration.ValidationMixin import ValidationMixin
|
||||
from plugin.base.label.mixins import LabelPrintingMixin
|
||||
from plugin.base.locate.mixins import LocateMixin
|
||||
@ -38,5 +39,7 @@ __all__ = [
|
||||
'SingleNotificationMethod',
|
||||
'SupplierBarcodeMixin',
|
||||
'UrlsMixin',
|
||||
'UrlsMixin',
|
||||
'UserInterfaceMixin',
|
||||
'ValidationMixin',
|
||||
]
|
||||
|
@ -742,11 +742,12 @@ class PluginsRegistry:
|
||||
def plugin_settings_keys(self):
|
||||
"""A list of keys which are used to store plugin settings."""
|
||||
return [
|
||||
'ENABLE_PLUGINS_URL',
|
||||
'ENABLE_PLUGINS_NAVIGATION',
|
||||
'ENABLE_PLUGINS_APP',
|
||||
'ENABLE_PLUGINS_SCHEDULE',
|
||||
'ENABLE_PLUGINS_EVENTS',
|
||||
'ENABLE_PLUGINS_INTERFACE',
|
||||
'ENABLE_PLUGINS_NAVIGATION',
|
||||
'ENABLE_PLUGINS_SCHEDULE',
|
||||
'ENABLE_PLUGINS_URL',
|
||||
]
|
||||
|
||||
def calculate_plugin_hash(self):
|
||||
|
@ -0,0 +1,30 @@
|
||||
{% load i18n %}
|
||||
|
||||
<h4>Custom Plugin Panel</h4>
|
||||
|
||||
<p>
|
||||
This content has been rendered by a custom plugin, and will be displayed for any "part" instance
|
||||
(as long as the plugin is enabled).
|
||||
This content has been rendered on the server, using the django templating system.
|
||||
</p>
|
||||
|
||||
<h5>Part Details</h5>
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<th>Part Name</th>
|
||||
<td>{{ part.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part Description</th>
|
||||
<td>{{ part.description }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part Category</th>
|
||||
<td>{{ part.category.pathstring }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Part IPN</th>
|
||||
<td>{% if part.IPN %}{{ part.IPN }}{% else %}<i>No IPN specified</i>{% endif %}</td>
|
||||
</tr>
|
||||
</table>
|
@ -0,0 +1,124 @@
|
||||
"""Sample plugin which demonstrates user interface integrations."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from part.models import Part
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.helpers import render_template, render_text
|
||||
from plugin.mixins import SettingsMixin, UserInterfaceMixin
|
||||
|
||||
|
||||
class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlugin):
|
||||
"""A sample plugin which demonstrates user interface integrations."""
|
||||
|
||||
NAME = 'SampleUI'
|
||||
SLUG = 'sampleui'
|
||||
TITLE = 'Sample User Interface Plugin'
|
||||
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
|
||||
VERSION = '1.0'
|
||||
|
||||
SETTINGS = {
|
||||
'ENABLE_PART_PANELS': {
|
||||
'name': _('Enable Part Panels'),
|
||||
'description': _('Enable custom panels for Part views'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'ENABLE_PURCHASE_ORDER_PANELS': {
|
||||
'name': _('Enable Purchase Order Panels'),
|
||||
'description': _('Enable custom panels for Purchase Order views'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'ENABLE_BROKEN_PANELS': {
|
||||
'name': _('Enable Broken Panels'),
|
||||
'description': _('Enable broken panels for testing'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'ENABLE_DYNAMIC_PANEL': {
|
||||
'name': _('Enable Dynamic Panel'),
|
||||
'description': _('Enable dynamic panels for testing'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
}
|
||||
|
||||
def get_custom_panels(self, instance_type: str, instance_id: int, request):
|
||||
"""Return a list of custom panels to be injected into the UI."""
|
||||
panels = []
|
||||
|
||||
# First, add a custom panel which will appear on every type of page
|
||||
# This panel will contain a simple message
|
||||
|
||||
content = render_text(
|
||||
"""
|
||||
This is a <i>sample panel</i> which appears on every page.
|
||||
It renders a simple string of <b>HTML</b> content.
|
||||
|
||||
<br>
|
||||
<h5>Instance Details:</h5>
|
||||
<ul>
|
||||
<li>Instance Type: {{ instance_type }}</li>
|
||||
<li>Instance ID: {{ instance_id }}</li>
|
||||
</ul>
|
||||
""",
|
||||
context={'instance_type': instance_type, 'instance_id': instance_id},
|
||||
)
|
||||
|
||||
panels.append({
|
||||
'name': 'sample_panel',
|
||||
'label': 'Sample Panel',
|
||||
'content': content,
|
||||
})
|
||||
|
||||
# A broken panel which tries to load a non-existent JS file
|
||||
if self.get_setting('ENABLE_BROKEN_PANElS'):
|
||||
panels.append({
|
||||
'name': 'broken_panel',
|
||||
'label': 'Broken Panel',
|
||||
'source': '/this/does/not/exist.js',
|
||||
})
|
||||
|
||||
# A dynamic panel which will be injected into the UI (loaded from external file)
|
||||
if self.get_setting('ENABLE_DYNAMIC_PANEL'):
|
||||
panels.append({
|
||||
'name': 'dynamic_panel',
|
||||
'label': 'Dynamic Part Panel',
|
||||
'source': '/static/plugin/sample_panel.js',
|
||||
'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':
|
||||
try:
|
||||
part = Part.objects.get(pk=instance_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,
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'purchaseorder' page
|
||||
if (
|
||||
self.get_setting('ENABLE_PURCHASE_ORDER_PANELS')
|
||||
and instance_type == 'purchaseorder'
|
||||
):
|
||||
panels.append({
|
||||
'name': 'purchase_order_panel',
|
||||
'label': 'Purchase Order Panel',
|
||||
'content': 'This is a custom panel which appears on the <b>Purchase Order</b> view page.',
|
||||
})
|
||||
|
||||
return panels
|
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* A sample panel plugin for InvenTree.
|
||||
*
|
||||
* This plugin file is dynamically loaded,
|
||||
* as specified in the plugin/samples/integration/user_interface_sample.py
|
||||
*
|
||||
* It provides a simple example of how panels can be dynamically rendered,
|
||||
* as well as dynamically hidden, based on the provided context.
|
||||
*/
|
||||
|
||||
export function renderPanel(target, context) {
|
||||
|
||||
if (!target) {
|
||||
console.error("No target provided to renderPanel");
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = `
|
||||
<h4>Dynamic Panel Content</h4>
|
||||
|
||||
<p>This panel has been dynamically rendered by the plugin system.</p>
|
||||
<p>It can be hidden or displayed based on the provided context.</p>
|
||||
|
||||
<hr>
|
||||
<h5>Context:</h5>
|
||||
|
||||
<ul>
|
||||
<li>Username: ${context.user.username()}</li>
|
||||
<li>Is Staff: ${context.user.isStaff() ? "YES": "NO"}</li>
|
||||
<li>Model Type: ${context.model}</li>
|
||||
<li>Instance ID: ${context.id}</li>
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Dynamically hide the panel based on the provided context
|
||||
export function isPanelHidden(context) {
|
||||
|
||||
// Hide the panel if the user is not staff
|
||||
if (!context?.user?.isStaff()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only display for active parts
|
||||
return context.model != 'part' || !context.instance || !context.instance.active;
|
||||
}
|
@ -301,3 +301,46 @@ class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, value):
|
||||
"""Return the 'key' of the PluginConfig object."""
|
||||
return value.key
|
||||
|
||||
|
||||
class PluginPanelSerializer(serializers.Serializer):
|
||||
"""Serializer for a plugin panel."""
|
||||
|
||||
class Meta:
|
||||
"""Meta for serializer."""
|
||||
|
||||
fields = [
|
||||
'plugin',
|
||||
'name',
|
||||
'label',
|
||||
# Following fields are optional
|
||||
'icon',
|
||||
'content',
|
||||
'source',
|
||||
]
|
||||
|
||||
# Required fields
|
||||
plugin = serializers.CharField(
|
||||
label=_('Plugin Key'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
name = serializers.CharField(
|
||||
label=_('Panel Name'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
label = serializers.CharField(
|
||||
label=_('Panel Title'), required=True, allow_blank=False
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
icon = serializers.CharField(
|
||||
label=_('Panel Icon'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
content = serializers.CharField(
|
||||
label=_('Panel Content (HTML)'), required=False, allow_blank=True
|
||||
)
|
||||
|
||||
source = serializers.CharField(
|
||||
label=_('Panel Source (javascript)'), required=False, allow_blank=True
|
||||
)
|
||||
|
@ -32,6 +32,8 @@ def clear_static_dir(path, recursive=True):
|
||||
# Finally, delete the directory itself to remove orphan folders when uninstalling a plugin
|
||||
staticfiles_storage.delete(path)
|
||||
|
||||
logger.info('Cleared static directory: %s', path)
|
||||
|
||||
|
||||
def collect_plugins_static_files():
|
||||
"""Copy static files from all installed plugins into the static directory."""
|
||||
@ -43,6 +45,26 @@ def collect_plugins_static_files():
|
||||
copy_plugin_static_files(slug, check_reload=False)
|
||||
|
||||
|
||||
def clear_plugins_static_files():
|
||||
"""Clear out static files for plugins which are no longer active."""
|
||||
installed_plugins = set(registry.plugins.keys())
|
||||
|
||||
path = 'plugins/'
|
||||
|
||||
# Check that the directory actually exists
|
||||
if not staticfiles_storage.exists(path):
|
||||
return
|
||||
|
||||
# Get all static files in the 'plugins' static directory
|
||||
dirs, _files = staticfiles_storage.listdir('plugins/')
|
||||
|
||||
for d in dirs:
|
||||
# Check if the directory is a plugin directory
|
||||
if d not in installed_plugins:
|
||||
# Clear out the static files for this plugin
|
||||
clear_static_dir(f'plugins/{d}/', recursive=True)
|
||||
|
||||
|
||||
def copy_plugin_static_files(slug, check_reload=True):
|
||||
"""Copy static files for the specified plugin."""
|
||||
if check_reload:
|
||||
@ -93,3 +115,8 @@ def copy_plugin_static_files(slug, check_reload=True):
|
||||
copied += 1
|
||||
|
||||
logger.info("Copied %s static files for plugin '%s'.", copied, slug)
|
||||
|
||||
|
||||
def clear_plugin_static_files(slug: str, recursive: bool = True):
|
||||
"""Clear static files for the specified plugin."""
|
||||
clear_static_dir(f'plugins/{slug}/', recursive=recursive)
|
||||
|
@ -38,7 +38,8 @@ export default defineConfig({
|
||||
{
|
||||
command: 'invoke dev.server -a 127.0.0.1:8000',
|
||||
env: {
|
||||
INVENTREE_DEBUG: 'True'
|
||||
INVENTREE_DEBUG: 'True',
|
||||
INVENTREE_PLUGINS_ENABLED: 'True'
|
||||
},
|
||||
url: 'http://127.0.0.1:8000/api/',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
14
src/frontend/src/components/nav/Panel.tsx
Normal file
14
src/frontend/src/components/nav/Panel.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Type used to specify a single panel in a panel group
|
||||
*/
|
||||
export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
showHeadline?: boolean;
|
||||
};
|
@ -20,29 +20,34 @@ import {
|
||||
useParams
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { cancelEvent } from '../../functions/events';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { usePluginPanels } from '../../hooks/UsePluginPanels';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { PanelType } from './Panel';
|
||||
|
||||
/**
|
||||
* Type used to specify a single panel in a panel group
|
||||
* Set of properties which define a panel group:
|
||||
*
|
||||
* @param pageKey - Unique key for this panel group
|
||||
* @param panels - List of panels to display
|
||||
* @param model - The target model for this panel group (e.g. 'part' / 'salesorder')
|
||||
* @param id - The target ID for this panel group (set to *null* for groups which do not target a specific model instance)
|
||||
* @param instance - The target model instance for this panel group
|
||||
* @param selectedPanel - The currently selected panel
|
||||
* @param onPanelChange - Callback when the active panel changes
|
||||
* @param collapsible - If true, the panel group can be collapsed (defaults to true)
|
||||
*/
|
||||
export type PanelType = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
showHeadline?: boolean;
|
||||
};
|
||||
|
||||
export type PanelProps = {
|
||||
pageKey: string;
|
||||
panels: PanelType[];
|
||||
instance?: any;
|
||||
model?: ModelType | string;
|
||||
id?: number | null;
|
||||
selectedPanel?: string;
|
||||
onPanelChange?: (panel: string) => void;
|
||||
collapsible?: boolean;
|
||||
@ -53,35 +58,39 @@ function BasePanelGroup({
|
||||
panels,
|
||||
onPanelChange,
|
||||
selectedPanel,
|
||||
instance,
|
||||
model,
|
||||
id,
|
||||
collapsible = true
|
||||
}: Readonly<PanelProps>): ReactNode {
|
||||
const localState = useLocalState();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { panel } = useParams();
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
|
||||
// Hook to load plugins for this panel
|
||||
const pluginPanels = usePluginPanels({
|
||||
model: model,
|
||||
instance: instance,
|
||||
id: id
|
||||
});
|
||||
|
||||
const allPanels = useMemo(
|
||||
() => [...panels, ...pluginPanels],
|
||||
[panels, pluginPanels]
|
||||
);
|
||||
|
||||
const activePanels = useMemo(
|
||||
() => panels.filter((panel) => !panel.hidden && !panel.disabled),
|
||||
[panels]
|
||||
() => allPanels.filter((panel) => !panel.hidden && !panel.disabled),
|
||||
[allPanels]
|
||||
);
|
||||
|
||||
const setLastUsedPanel = useLocalState((state) =>
|
||||
state.setLastUsedPanel(pageKey)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel) {
|
||||
setLastUsedPanel(panel);
|
||||
}
|
||||
// panel is intentionally no dependency as this should only run on initial render
|
||||
}, [setLastUsedPanel]);
|
||||
|
||||
// Callback when the active panel changes
|
||||
const handlePanelChange = useCallback(
|
||||
(panel: string | null, event?: any) => {
|
||||
if (activePanels.findIndex((p) => p.name === panel) === -1) {
|
||||
panel = '';
|
||||
}
|
||||
|
||||
(panel: string, event?: any) => {
|
||||
if (event && (event?.ctrlKey || event?.shiftKey)) {
|
||||
const url = `${location.pathname}/../${panel}`;
|
||||
cancelEvent(event);
|
||||
@ -90,12 +99,14 @@ function BasePanelGroup({
|
||||
navigate(`../${panel}`);
|
||||
}
|
||||
|
||||
localState.setLastUsedPanel(pageKey)(panel);
|
||||
|
||||
// Optionally call external callback hook
|
||||
if (panel && onPanelChange) {
|
||||
onPanelChange(panel);
|
||||
}
|
||||
},
|
||||
[activePanels, setLastUsedPanel, navigate, location, onPanelChange]
|
||||
[activePanels, navigate, location, onPanelChange]
|
||||
);
|
||||
|
||||
// if the selected panel state changes update the current panel
|
||||
@ -105,32 +116,32 @@ function BasePanelGroup({
|
||||
}
|
||||
}, [selectedPanel, panel]);
|
||||
|
||||
// Update the active panel when panels changes and the active is no longer available
|
||||
useEffect(() => {
|
||||
// Determine the current panels selection (must be a valid panel)
|
||||
const currentPanel: string = useMemo(() => {
|
||||
if (activePanels.findIndex((p) => p.name === panel) === -1) {
|
||||
setLastUsedPanel('');
|
||||
return navigate('../');
|
||||
return activePanels[0]?.name ?? '';
|
||||
} else {
|
||||
return panel ?? '';
|
||||
}
|
||||
}, [activePanels, panel]);
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
|
||||
return (
|
||||
<Boundary label={`PanelGroup-${pageKey}`}>
|
||||
<Paper p="sm" radius="xs" shadow="xs">
|
||||
<Tabs value={panel} orientation="vertical" keepMounted={false}>
|
||||
<Tabs value={currentPanel} orientation="vertical" keepMounted={false}>
|
||||
<Tabs.List justify="left">
|
||||
{panels.map(
|
||||
{allPanels.map(
|
||||
(panel) =>
|
||||
!panel.hidden && (
|
||||
<Tooltip
|
||||
label={panel.label}
|
||||
label={`tooltip-${panel.name}`}
|
||||
key={panel.name}
|
||||
disabled={expanded}
|
||||
position="right"
|
||||
>
|
||||
<Tabs.Tab
|
||||
p="xs"
|
||||
key={`panel-label-${panel.name}`}
|
||||
value={panel.name}
|
||||
leftSection={panel.icon}
|
||||
hidden={panel.hidden}
|
||||
@ -162,11 +173,11 @@ function BasePanelGroup({
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Tabs.List>
|
||||
{panels.map(
|
||||
{allPanels.map(
|
||||
(panel) =>
|
||||
!panel.hidden && (
|
||||
<Tabs.Panel
|
||||
key={panel.name}
|
||||
key={`panel-${panel.name}`}
|
||||
value={panel.name}
|
||||
aria-label={`nav-panel-${identifierString(
|
||||
`${pageKey}-${panel.name}`
|
||||
|
36
src/frontend/src/components/plugins/PluginContext.tsx
Normal file
36
src/frontend/src/components/plugins/PluginContext.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { MantineColorScheme, MantineTheme } from '@mantine/core';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { SettingsStateProps } from '../../states/SettingsState';
|
||||
import { UserStateProps } from '../../states/UserState';
|
||||
|
||||
/*
|
||||
* A set of properties which are passed to a plugin,
|
||||
* 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 user - The current user instance (see ../states/UserState.tsx)
|
||||
* @param userSettings - The current user settings (see ../states/SettingsState.tsx)
|
||||
* @param globalSettings - The global settings (see ../states/SettingsState.tsx)
|
||||
* @param navigate - The navigation function (see react-router-dom)
|
||||
* @param theme - The current Mantine theme
|
||||
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
|
||||
*/
|
||||
export type PluginContext = {
|
||||
model?: ModelType | string;
|
||||
id?: string | number | null;
|
||||
instance?: any;
|
||||
api: AxiosInstance;
|
||||
user: UserStateProps;
|
||||
userSettings: SettingsStateProps;
|
||||
globalSettings: SettingsStateProps;
|
||||
host: string;
|
||||
navigate: NavigateFunction;
|
||||
theme: MantineTheme;
|
||||
colorScheme: MantineColorScheme;
|
||||
};
|
122
src/frontend/src/components/plugins/PluginPanel.tsx
Normal file
122
src/frontend/src/components/plugins/PluginPanel.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { PluginContext } from './PluginContext';
|
||||
import { findExternalPluginFunction } from './PluginSource';
|
||||
|
||||
// Definition of the plugin panel properties, provided by the server API
|
||||
export type PluginPanelProps = {
|
||||
plugin: string;
|
||||
name: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export async function isPluginPanelHidden({
|
||||
pluginProps,
|
||||
pluginContext
|
||||
}: {
|
||||
pluginProps: PluginPanelProps;
|
||||
pluginContext: PluginContext;
|
||||
}): Promise<boolean> {
|
||||
if (!pluginProps.source) {
|
||||
// No custom source supplied - panel is not hidden
|
||||
return false;
|
||||
}
|
||||
|
||||
const func = await findExternalPluginFunction(
|
||||
pluginProps.source,
|
||||
'isPanelHidden'
|
||||
);
|
||||
|
||||
if (!func) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return func(pluginContext);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error occurred while checking if plugin panel is hidden:',
|
||||
error
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom panel which can be used to display plugin content.
|
||||
*
|
||||
* - Content is loaded dynamically (via the API) when a page is first loaded
|
||||
* - Content can be provided from an external javascript module, or with raw HTML
|
||||
*
|
||||
* If content is provided from an external source, it is expected to define a function `render_panel` which will render the content.
|
||||
* const render_panel = (element: HTMLElement, params: any) => {...}
|
||||
*
|
||||
* Where:
|
||||
* - `element` is the HTML element to render the content into
|
||||
* - `params` is the set of run-time parameters to pass to the content rendering function
|
||||
*/
|
||||
export default function PluginPanelContent({
|
||||
pluginProps,
|
||||
pluginContext
|
||||
}: {
|
||||
pluginProps: PluginPanelProps;
|
||||
pluginContext: PluginContext;
|
||||
}): ReactNode {
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const reloadPluginContent = async () => {
|
||||
// If a "source" URL is provided, load the content from that URL
|
||||
if (pluginProps.source) {
|
||||
findExternalPluginFunction(pluginProps.source, 'renderPanel').then(
|
||||
(func) => {
|
||||
if (func) {
|
||||
try {
|
||||
func(ref.current, pluginContext);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
setError(t`Error occurred while rendering plugin content`);
|
||||
}
|
||||
} else {
|
||||
setError(t`Plugin did not provide panel rendering function`);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (pluginProps.content) {
|
||||
// If content is provided directly, render it into the panel
|
||||
if (ref.current) {
|
||||
ref.current?.setHTMLUnsafe(pluginProps.content.toString());
|
||||
setError('');
|
||||
}
|
||||
} else {
|
||||
// If no content is provided, display a placeholder
|
||||
setError(t`No content provided for this plugin`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadPluginContent();
|
||||
}, [pluginProps, pluginContext]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t`Error Loading Plugin`}
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div ref={ref as any}></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
47
src/frontend/src/components/plugins/PluginSource.tsx
Normal file
47
src/frontend/src/components/plugins/PluginSource.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
|
||||
/*
|
||||
* Load an external plugin source from a URL.
|
||||
*/
|
||||
export async function loadExternalPluginSource(source: string) {
|
||||
const host = useLocalState.getState().host;
|
||||
|
||||
source = source.trim();
|
||||
|
||||
// If no source is provided, clear the plugin content
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the source is a relative URL, prefix it with the host URL
|
||||
if (source.startsWith('/')) {
|
||||
source = `${host}${source}`;
|
||||
}
|
||||
|
||||
const module = await import(/* @vite-ignore */ source)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load plugin source:', error);
|
||||
return null;
|
||||
})
|
||||
.then((module) => {
|
||||
return module;
|
||||
});
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
/*
|
||||
* Find a named function in an external plugin source.
|
||||
*/
|
||||
export async function findExternalPluginFunction(
|
||||
source: string,
|
||||
functionName: string
|
||||
) {
|
||||
const module = await loadExternalPluginSource(source);
|
||||
|
||||
if (module && module[functionName]) {
|
||||
return module[functionName];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -182,6 +182,9 @@ export enum ApiEndpoints {
|
||||
plugin_activate = 'plugins/:key/activate/',
|
||||
plugin_uninstall = 'plugins/:key/uninstall/',
|
||||
|
||||
// User interface plugin endpoints
|
||||
plugin_panel_list = 'plugins/ui/panels/',
|
||||
|
||||
// Machine API endpoints
|
||||
machine_types_list = 'machine/types/',
|
||||
machine_driver_list = 'machine/drivers/',
|
||||
|
@ -57,6 +57,7 @@ import {
|
||||
IconPaperclip,
|
||||
IconPhone,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconPoint,
|
||||
IconPrinter,
|
||||
IconProgressCheck,
|
||||
@ -217,7 +218,8 @@ const icons = {
|
||||
destination: IconFlag,
|
||||
repeat_destination: IconFlagShare,
|
||||
unlink: IconUnlink,
|
||||
success: IconCircleCheck
|
||||
success: IconCircleCheck,
|
||||
plugin: IconPlug
|
||||
};
|
||||
|
||||
export type InvenTreeIconType = keyof typeof icons;
|
||||
|
149
src/frontend/src/hooks/UsePluginPanels.tsx
Normal file
149
src/frontend/src/hooks/UsePluginPanels.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../App';
|
||||
import { PanelType } from '../components/nav/Panel';
|
||||
import { PluginContext } from '../components/plugins/PluginContext';
|
||||
import {
|
||||
PluginPanelProps,
|
||||
isPluginPanelHidden
|
||||
} from '../components/plugins/PluginPanel';
|
||||
import PluginPanelContent from '../components/plugins/PluginPanel';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { identifierString } from '../functions/conversion';
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../states/SettingsState';
|
||||
import { useUserState } from '../states/UserState';
|
||||
|
||||
export function usePluginPanels({
|
||||
instance,
|
||||
model,
|
||||
id
|
||||
}: {
|
||||
instance?: any;
|
||||
model?: ModelType | string;
|
||||
id?: string | number | null;
|
||||
}): PanelType[] {
|
||||
const host = useLocalState.getState().host;
|
||||
const navigate = useNavigate();
|
||||
const user = useUserState();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const theme = useMantineTheme();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const pluginPanelsEnabled: boolean = useMemo(
|
||||
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||
[globalSettings]
|
||||
);
|
||||
|
||||
// API query to fetch initial information on available plugin panels
|
||||
const { data: pluginData } = useQuery({
|
||||
enabled: pluginPanelsEnabled && !!model && id !== undefined,
|
||||
queryKey: ['custom-plugin-panels', model, id],
|
||||
queryFn: async () => {
|
||||
if (!pluginPanelsEnabled || !model) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return api
|
||||
.get(apiUrl(ApiEndpoints.plugin_panel_list), {
|
||||
params: {
|
||||
target_model: model,
|
||||
target_id: id
|
||||
}
|
||||
})
|
||||
.then((response: any) => response.data)
|
||||
.catch((error: any) => {
|
||||
console.error('Failed to fetch plugin panels:', error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the context data which is delivered to the plugins
|
||||
const contextData: PluginContext = useMemo(() => {
|
||||
return {
|
||||
model: model,
|
||||
id: id,
|
||||
instance: instance,
|
||||
user: user,
|
||||
host: host,
|
||||
api: api,
|
||||
navigate: navigate,
|
||||
globalSettings: globalSettings,
|
||||
userSettings: userSettings,
|
||||
theme: theme,
|
||||
colorScheme: colorScheme
|
||||
};
|
||||
}, [
|
||||
model,
|
||||
id,
|
||||
instance,
|
||||
user,
|
||||
host,
|
||||
api,
|
||||
navigate,
|
||||
globalSettings,
|
||||
userSettings,
|
||||
theme,
|
||||
colorScheme
|
||||
]);
|
||||
|
||||
// Track which panels are hidden: { panelName: true/false }
|
||||
// We need to memoize this as the plugins can determine this dynamically
|
||||
const [panelState, setPanelState] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Clear the visibility cache when the plugin data changes
|
||||
// This will force the plugin panels to re-calculate their visibility
|
||||
useEffect(() => {
|
||||
pluginData?.forEach((props: PluginPanelProps) => {
|
||||
const identifier = identifierString(
|
||||
`plugin-panel-${props.plugin}-${props.name}`
|
||||
);
|
||||
|
||||
// Check if the panel is hidden (defaults to true until we know otherwise)
|
||||
isPluginPanelHidden({
|
||||
pluginProps: props,
|
||||
pluginContext: contextData
|
||||
}).then((result) => {
|
||||
setPanelState((prev) => ({ ...prev, [identifier]: result }));
|
||||
});
|
||||
});
|
||||
}, [pluginData, contextData]);
|
||||
|
||||
const pluginPanels: PanelType[] = useMemo(() => {
|
||||
return (
|
||||
pluginData?.map((props: PluginPanelProps) => {
|
||||
const iconName: string = props.icon || 'plugin';
|
||||
const identifier = identifierString(
|
||||
`plugin-panel-${props.plugin}-${props.name}`
|
||||
);
|
||||
const isHidden: boolean = panelState[identifier] ?? true;
|
||||
|
||||
return {
|
||||
name: identifier,
|
||||
label: props.label,
|
||||
icon: <InvenTreeIcon icon={iconName as InvenTreeIconType} />,
|
||||
content: (
|
||||
<PluginPanelContent
|
||||
pluginProps={props}
|
||||
pluginContext={contextData}
|
||||
/>
|
||||
),
|
||||
hidden: isHidden
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [panelState, pluginData, contextData]);
|
||||
|
||||
return pluginPanels;
|
||||
}
|
@ -11,6 +11,7 @@ import ReactDOM from 'react-dom/client';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
import { api } from './App';
|
||||
import { HostList } from './states/states';
|
||||
import MainView from './views/MainView';
|
||||
|
||||
@ -25,6 +26,8 @@ declare global {
|
||||
sentry_dsn?: string;
|
||||
environment?: string;
|
||||
};
|
||||
InvenTreeAPI: typeof api;
|
||||
React: typeof React;
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,3 +102,6 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
if (window.location.pathname === '/') {
|
||||
window.location.replace(`/${base_url}`);
|
||||
}
|
||||
|
||||
window.React = React;
|
||||
window.InvenTreeAPI = api;
|
||||
|
@ -28,7 +28,8 @@ import { lazy, useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../../../components/errors/PermissionDenied';
|
||||
import { PlaceholderPill } from '../../../../components/items/Placeholder';
|
||||
import { PanelGroup, PanelType } from '../../../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Accordion, Alert, Stack } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { userInfo } from 'os';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
import { useServerApiState } from '../../../../states/ApiState';
|
||||
import { useUserState } from '../../../../states/UserState';
|
||||
|
||||
const PluginListTable = Loadable(
|
||||
lazy(() => import('../../../../tables/plugin/PluginListTable'))
|
||||
@ -21,6 +23,8 @@ export default function PluginManagementPanel() {
|
||||
(state) => state.server.plugins_enabled
|
||||
);
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{!pluginsEnabled && (
|
||||
@ -45,15 +49,6 @@ export default function PluginManagementPanel() {
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="pluginerror">
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{t`Plugin Errors`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PluginErrorTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="pluginsettings">
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{t`Plugin Settings`}</StylishText>
|
||||
@ -63,6 +58,7 @@ export default function PluginManagementPanel() {
|
||||
keys={[
|
||||
'ENABLE_PLUGINS_SCHEDULE',
|
||||
'ENABLE_PLUGINS_EVENTS',
|
||||
'ENABLE_PLUGINS_INTERFACE',
|
||||
'ENABLE_PLUGINS_URL',
|
||||
'ENABLE_PLUGINS_NAVIGATION',
|
||||
'ENABLE_PLUGINS_APP',
|
||||
@ -72,6 +68,16 @@ export default function PluginManagementPanel() {
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{user.isSuperuser() && (
|
||||
<Accordion.Item value="pluginerror">
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{t`Plugin Errors`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PluginErrorTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
|
@ -21,7 +21,8 @@ import { useMemo } from 'react';
|
||||
|
||||
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
||||
import { PlaceholderPanel } from '../../../components/items/Placeholder';
|
||||
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { useServerApiState } from '../../../states/ApiState';
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../../components/nav/PanelGroup';
|
||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
|
@ -33,7 +33,8 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -536,7 +537,13 @@ export default function BuildDetail() {
|
||||
]}
|
||||
actions={buildActions}
|
||||
/>
|
||||
<PanelGroup pageKey="build" panels={buildPanels} />
|
||||
<PanelGroup
|
||||
pageKey="build"
|
||||
panels={buildPanels}
|
||||
instance={build}
|
||||
model={ModelType.build}
|
||||
id={build.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -31,7 +31,8 @@ import {
|
||||
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -344,7 +345,13 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
editAction={editCompany.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.company)}
|
||||
/>
|
||||
<PanelGroup pageKey="company" panels={companyPanels} />
|
||||
<PanelGroup
|
||||
pageKey="company"
|
||||
panels={companyPanels}
|
||||
instance={company}
|
||||
model={ModelType.company}
|
||||
id={company.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -23,7 +23,8 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -284,7 +285,13 @@ export default function ManufacturerPartDetail() {
|
||||
editAction={editManufacturerPart.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.manufacturerpart)}
|
||||
/>
|
||||
<PanelGroup pageKey="manufacturerpart" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="manufacturerpart"
|
||||
panels={panels}
|
||||
instance={manufacturerPart}
|
||||
model={ModelType.manufacturerpart}
|
||||
id={manufacturerPart.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -25,7 +25,8 @@ import {
|
||||
} from '../../components/items/ActionDropdown';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -361,7 +362,13 @@ export default function SupplierPartDetail() {
|
||||
editAction={editSupplierPart.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.supplierpart)}
|
||||
/>
|
||||
<PanelGroup pageKey="supplierpart" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="supplierpart"
|
||||
panels={panels}
|
||||
instance={supplierPart}
|
||||
model={ModelType.supplierpart}
|
||||
id={supplierPart.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -21,7 +21,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -229,7 +230,7 @@ export default function CategoryDetail() {
|
||||
];
|
||||
}, [id, user, category.pk]);
|
||||
|
||||
const categoryPanels: PanelType[] = useMemo(
|
||||
const panels: PanelType[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'details',
|
||||
@ -311,7 +312,13 @@ export default function CategoryDetail() {
|
||||
editAction={editCategory.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.partcategory)}
|
||||
/>
|
||||
<PanelGroup pageKey="partcategory" panels={categoryPanels} />
|
||||
<PanelGroup
|
||||
pageKey="partcategory"
|
||||
panels={panels}
|
||||
model={ModelType.partcategory}
|
||||
instance={category}
|
||||
id={category.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -58,7 +58,8 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -1122,7 +1123,13 @@ export default function PartDetail() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PanelGroup pageKey="part" panels={partPanels} />
|
||||
<PanelGroup
|
||||
pageKey="part"
|
||||
panels={partPanels}
|
||||
instance={part}
|
||||
model={ModelType.part}
|
||||
id={part.pk}
|
||||
/>
|
||||
{transferStockItems.modal}
|
||||
{countStockItems.modal}
|
||||
</Stack>
|
||||
|
@ -28,7 +28,8 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -469,7 +470,13 @@ export default function PurchaseOrderDetail() {
|
||||
editAction={editPurchaseOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.purchaseorder)}
|
||||
/>
|
||||
<PanelGroup pageKey="purchaseorder" panels={orderPanels} />
|
||||
<PanelGroup
|
||||
pageKey="purchaseorder"
|
||||
panels={orderPanels}
|
||||
model={ModelType.purchaseorder}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -58,7 +58,12 @@ export default function PurchasingIndex() {
|
||||
return (
|
||||
<Stack>
|
||||
<PageDetail title={t`Purchasing`} />
|
||||
<PanelGroup pageKey="purchasing-index" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="purchasing-index"
|
||||
panels={panels}
|
||||
model={'purchasing'}
|
||||
id={null}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -27,7 +27,8 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -458,7 +459,13 @@ export default function ReturnOrderDetail() {
|
||||
editAction={editReturnOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.returnorder)}
|
||||
/>
|
||||
<PanelGroup pageKey="returnorder" panels={orderPanels} />
|
||||
<PanelGroup
|
||||
pageKey="returnorder"
|
||||
panels={orderPanels}
|
||||
model={ModelType.returnorder}
|
||||
instance={order}
|
||||
id={order.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -51,7 +51,12 @@ export default function PurchasingIndex() {
|
||||
return (
|
||||
<Stack>
|
||||
<PageDetail title={t`Sales`} />
|
||||
<PanelGroup pageKey="sales-index" panels={panels} />
|
||||
<PanelGroup
|
||||
pageKey="sales-index"
|
||||
panels={panels}
|
||||
model={'sales'}
|
||||
id={null}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ import {
|
||||
import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -509,7 +510,13 @@ export default function SalesOrderDetail() {
|
||||
editAction={editSalesOrder.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.salesorder)}
|
||||
/>
|
||||
<PanelGroup pageKey="salesorder" panels={orderPanels} />
|
||||
<PanelGroup
|
||||
pageKey="salesorder"
|
||||
panels={orderPanels}
|
||||
model={ModelType.salesorder}
|
||||
id={order.pk}
|
||||
instance={order}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
@ -20,7 +20,8 @@ import { ApiIcon } from '../../components/items/ApiIcon';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@ -387,7 +388,13 @@ export default function Stock() {
|
||||
setTreeOpen(true);
|
||||
}}
|
||||
/>
|
||||
<PanelGroup pageKey="stocklocation" panels={locationPanels} />
|
||||
<PanelGroup
|
||||
pageKey="stocklocation"
|
||||
panels={locationPanels}
|
||||
model={ModelType.stocklocation}
|
||||
id={location.pk}
|
||||
instance={location}
|
||||
/>
|
||||
{transferStockItems.modal}
|
||||
{countStockItems.modal}
|
||||
</Stack>
|
||||
|
@ -33,7 +33,8 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/Panel';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -616,7 +617,13 @@ export default function StockDetail() {
|
||||
}}
|
||||
actions={stockActions}
|
||||
/>
|
||||
<PanelGroup pageKey="stockitem" panels={stockPanels} />
|
||||
<PanelGroup
|
||||
pageKey="stockitem"
|
||||
panels={stockPanels}
|
||||
model={ModelType.stockitem}
|
||||
id={stockitem.pk}
|
||||
instance={stockitem}
|
||||
/>
|
||||
{editStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{deleteStockItem.modal}
|
||||
|
@ -8,7 +8,7 @@ import { clearCsrfCookie } from '../functions/auth';
|
||||
import { apiUrl } from './ApiState';
|
||||
import { UserProps } from './states';
|
||||
|
||||
interface UserStateProps {
|
||||
export interface UserStateProps {
|
||||
user: UserProps | undefined;
|
||||
token: string | undefined;
|
||||
username: () => string;
|
||||
|
@ -352,7 +352,10 @@ export default function PluginListTable() {
|
||||
// Determine available actions for a given plugin
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
// TODO: Plugin actions should be updated based on on the users's permissions
|
||||
// Only superuser can perform plugin actions
|
||||
if (!user.isSuperuser()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let actions: RowAction[] = [];
|
||||
|
||||
@ -505,33 +508,28 @@ export default function PluginListTable() {
|
||||
|
||||
// Custom table actions
|
||||
const tableActions = useMemo(() => {
|
||||
let actions = [];
|
||||
|
||||
if (user.user?.is_superuser && pluginsEnabled) {
|
||||
actions.push(
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconRefresh />}
|
||||
tooltip={t`Reload Plugins`}
|
||||
onClick={reloadPlugins}
|
||||
/>
|
||||
);
|
||||
|
||||
actions.push(
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconPlaylistAdd />}
|
||||
tooltip={t`Install Plugin`}
|
||||
onClick={() => {
|
||||
setPluginPackage('');
|
||||
installPluginModal.open();
|
||||
}}
|
||||
disabled={plugins_install_disabled || false}
|
||||
/>
|
||||
);
|
||||
if (!user.isSuperuser() || !pluginsEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return actions;
|
||||
return [
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconRefresh />}
|
||||
tooltip={t`Reload Plugins`}
|
||||
onClick={reloadPlugins}
|
||||
/>,
|
||||
<ActionButton
|
||||
color="green"
|
||||
icon={<IconPlaylistAdd />}
|
||||
tooltip={t`Install Plugin`}
|
||||
onClick={() => {
|
||||
setPluginPackage('');
|
||||
installPluginModal.open();
|
||||
}}
|
||||
disabled={plugins_install_disabled || false}
|
||||
/>
|
||||
];
|
||||
}, [user, pluginsEnabled]);
|
||||
|
||||
return (
|
||||
|
@ -69,7 +69,7 @@ test('PUI - Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||
.getByRole('table')
|
||||
.getByText('Wood Screw')
|
||||
.click();
|
||||
await page.waitForURL('**/part/98/pricing');
|
||||
await page.waitForURL('**/part/98/**');
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Pricing (Supplier)', async ({ page }) => {
|
||||
@ -121,7 +121,7 @@ test('PUI - Pages - Part - Pricing (Variant)', async ({ page }) => {
|
||||
let target = page.getByText('Green Chair').first();
|
||||
await target.waitFor();
|
||||
await target.click();
|
||||
await page.waitForURL('**/part/109/pricing');
|
||||
await page.waitForURL('**/part/109/**');
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Pricing (Internal)', async ({ page }) => {
|
||||
|
104
src/frontend/tests/pui_plugins.spec.ts
Normal file
104
src/frontend/tests/pui_plugins.spec.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import test, { Page, expect, request } from 'playwright/test';
|
||||
|
||||
import { baseUrl } from './defaults.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
/*
|
||||
* Set the value of a global setting in the database
|
||||
*/
|
||||
const setSettingState = async ({
|
||||
request,
|
||||
setting,
|
||||
value
|
||||
}: {
|
||||
request: any;
|
||||
setting: string;
|
||||
value: any;
|
||||
}) => {
|
||||
const url = `http://localhost:8000/api/settings/global/${setting}/`;
|
||||
|
||||
const response = await request.patch(url, {
|
||||
data: {
|
||||
value: value
|
||||
},
|
||||
headers: {
|
||||
// Basic username: password authorization
|
||||
Authorization: `Basic ${btoa('admin:inventree')}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(await response.status()).toBe(200);
|
||||
};
|
||||
|
||||
const setPluginState = async ({
|
||||
request,
|
||||
plugin,
|
||||
state
|
||||
}: {
|
||||
request: any;
|
||||
plugin: string;
|
||||
state: boolean;
|
||||
}) => {
|
||||
const url = `http://localhost:8000/api/plugins/${plugin}/activate/`;
|
||||
|
||||
const response = await request.patch(url, {
|
||||
data: {
|
||||
active: state
|
||||
},
|
||||
headers: {
|
||||
// Basic username: password authorization
|
||||
Authorization: `Basic ${btoa('admin:inventree')}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(await response.status()).toBe(200);
|
||||
};
|
||||
|
||||
test('Plugins - Panels', async ({ page, request }) => {
|
||||
await doQuickLogin(page, 'admin', 'inventree');
|
||||
|
||||
// Ensure that UI plugins are enabled
|
||||
await setSettingState({
|
||||
request,
|
||||
setting: 'ENABLE_PLUGINS_INTERFACE',
|
||||
value: true
|
||||
});
|
||||
|
||||
// Ensure that the SampleUI plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sampleui',
|
||||
state: true
|
||||
});
|
||||
|
||||
// Navigate to the "part" page
|
||||
await page.goto(`${baseUrl}/part/69/`);
|
||||
|
||||
// Ensure basic part tab is available
|
||||
await page.getByRole('tab', { name: 'Part Details' }).waitFor();
|
||||
|
||||
// Check out each of the plugin panels
|
||||
await page.getByRole('tab', { name: 'Sample Panel' }).click();
|
||||
await page
|
||||
.getByText('This is a sample panel which appears on every page')
|
||||
.waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Broken Panel' }).click();
|
||||
await page.getByText('Error Loading Plugin').waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Dynamic Part Panel' }).click();
|
||||
await page
|
||||
.getByText('This panel has been dynamically rendered by the plugin system')
|
||||
.waitFor();
|
||||
await page.getByText('Instance ID: 69');
|
||||
|
||||
await page.getByRole('tab', { name: 'Part Panel', exact: true }).click();
|
||||
await page.getByText('This content has been rendered by a custom plugin');
|
||||
|
||||
// Disable the plugin, and ensure it is no longer visible
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sampleui',
|
||||
state: false
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user