mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-02 13:28:49 +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 63a06d97f58ae15d837babb799b9ed8b22c3802b. * 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:
parent
df8269df2a
commit
12d2865b59
@ -4,6 +4,12 @@ title: Panel Mixin
|
||||
|
||||
## PanelMixin
|
||||
|
||||
!!! warning "Legacy User Interface"
|
||||
This plugin mixin class is designed specifically for the the *legacy* user interface (which is rendered on the server using django templates). The new user interface (which is rendered on the client using React) does not support this mixin class. Instead, refer to the new [User Interface Mixin](./ui.md) class.
|
||||
|
||||
!!! warning "Deprecated Class"
|
||||
This mixin class is considered deprecated, and will be removed in the 1.0.0 release.
|
||||
|
||||
The `PanelMixin` enables plugins to render custom content to "panels" on individual pages in the web interface.
|
||||
|
||||
Most pages in the web interface support multiple panels, which are selected via the sidebar menu on the left side of the screen:
|
||||
|
102
docs/docs/extend/plugins/ui.md
Normal file
102
docs/docs/extend/plugins/ui.md
Normal file
@ -0,0 +1,102 @@
|
||||
---
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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") }}
|
||||
|
||||
## Custom 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_custom_panels` method:
|
||||
|
||||
::: plugin.base.integration.UserInterfaceMixin.UserInterfaceMixin.get_custom_panels
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
show_sources: True
|
||||
summary: False
|
||||
members: []
|
||||
|
||||
The custom panels can display content which is generated either on the server side, or on the client side (see below).
|
||||
|
||||
### Server Side Rendering
|
||||
|
||||
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 = "<h1>Hello, world!</h1>";
|
||||
}
|
||||
```
|
||||
|
||||
#### 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;
|
||||
}
|
||||
```
|
||||
|
||||
## Sample Plugin
|
||||
|
||||
A sample plugin which implements custom user interface functionality is provided in the InvenTree source code:
|
||||
|
||||
::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
show_source: True
|
||||
members: []
|
@ -208,7 +208,6 @@ Refer to the [return order settings](../order/return_order.md#return-order-setti
|
||||
|
||||
### Plugin Settings
|
||||
|
||||
|
||||
| Name | Description | Default | Units |
|
||||
| ---- | ----------- | ------- | ----- |
|
||||
{{ globalsetting("PLUGIN_ON_STARTUP") }}
|
||||
@ -218,3 +217,4 @@ Refer to the [return order settings](../order/return_order.md#return-order-setti
|
||||
{{ globalsetting("ENABLE_PLUGINS_APP") }}
|
||||
{{ globalsetting("ENABLE_PLUGINS_SCHEDULE") }}
|
||||
{{ globalsetting("ENABLE_PLUGINS_EVENTS") }}
|
||||
{{ globalsetting("ENABLE_PLUGINS_INTERFACE") }}
|
||||
|
@ -26,7 +26,7 @@ The InvenTree server tries to locate the `config.yaml` configuration file on sta
|
||||
|
||||
The configuration file *template* can be found on [GitHub]({{ sourcefile("src/backend/InvenTree/config_template.yaml") }}), and is shown below:
|
||||
|
||||
{{ includefile("src/backend/InvenTree/config_template.yaml", "Configuration File Template", format="yaml") }}
|
||||
{{ includefile("src/backend/InvenTree/config_template.yaml", "Configuration File Template", fmt="yaml") }}
|
||||
|
||||
!!! info "Template File"
|
||||
The default configuration file (as defined by the template linked above) will be copied to the specified configuration file location on first run, if a configuration file is not found in that location.
|
||||
|
@ -213,6 +213,7 @@ nav:
|
||||
- Schedule Mixin: extend/plugins/schedule.md
|
||||
- Settings Mixin: extend/plugins/settings.md
|
||||
- URL Mixin: extend/plugins/urls.md
|
||||
- User Interface Mixin: extend/plugins/ui.md
|
||||
- Validation Mixin: extend/plugins/validation.md
|
||||
- Machines:
|
||||
- Overview: extend/machines/overview.md
|
||||
|
@ -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
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user