2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 03:55:41 +00:00

[PUI] Dashboard refactor (#8278)

* Refactor plugin components into <RemoteComponent />

* Clean up footer

* Allow BuildOrder list to be sorted by 'outstanding'

* Fix model name

* Update BuildOrderTable filter

* Add StockItemTable column

* Working towards new dashboard

* Cleanup unused imports

* Updates: Now rendering some custom widgets

* Define icons for model types

* Add icon

* Cleanup / refactor / delete

- Complete transfer of files into new structure

* Follow link for query count widgets

* Add some more widgets to the library

* Remove old dashboard link in header

* Remove feedback widget

* Bump API version

* Remove test widget

* Rename "Home" -> "Dashboard"

* Add some more widgets

* Pass 'editable' property through to widgets

* Cleanup

* Add drawer for selecting new widgets

* Allow different layouts per user on the same machine

* Fixes

* Add ability to *remove* widgets

* Add helpful button

* Add a keyboard shortcut

* Refactoring

* Add backend code for serving custom dashboard items

* Load dashboard items from plugins

* Tweak for dashboard item API query

- Refetch if user changes
- Tweak "loaded" value
- Prevent refetchOnMount

* Add message if no dashboard widgets are displayed

* Refactoring main navigation menu

- Group into sections
- Cleanup / consolidation
- General refactoring

* Remove playground

* Add backend field for storing dashboard layout

* Add extra type definitions for UseInstance

* Manual labels for builtin dashboard items

- Otherwise they will change with translation locale

* Shorten labels for more plugins

* Adjust DashboardMenu

* Reduce stored data

* Add widget filter by text

* Remove back-end settings

* Update playwright tests for dashboard

* Updated tests

* Refactor backend API for fetching plugin features

* Further fixes for back-end code

* More back-end fixes

* Refactor frontend:

- Custom panels
- Custom dashboard items

* Further backend fixes

* Yet more backend fixes

- Improve error handling

* Fix for custom plugin settings rendering

* Enable plugin panels for part index and stock index pages

* Cleanup

* Fix nav menu

* Update typing

* Helper func to return all plugin settings as a dict

* Update API version date

* Fix for UseInstancea

* typing fix

* Tweak layout callbacks

* Pass query parameters through to navigation functions

* Improve custom query display

* Add "news" widget

* Ensure links are prepended with base URL on receipt

* Update NewsWidget

* Bug fix

* Refactor template editor tests

* Refactor unit testing for test_ui_panels

* Unit test for dashboard item API endpoint

* Update comment

* Adjust playwright tests

* More playwright fixes

* Hide barcode scanning options if disabled

* Tweak dashboard widget

* Fix custom panel title

* Update documentation around UIMixin class

* Cleanup

* Additional docs

* Add icon def for 'error' ModelType

* Add error boundary to TemplateEditor component

* Fix so that it works with template editors and previews again

* Tweak error messages

* API unit test fixes

* Unit test fix

* More unit test fixes

* Playwright test tweaks

* Adjust error messages
This commit is contained in:
Oliver
2024-11-02 08:48:29 +11:00
committed by GitHub
parent c4031dba7f
commit 18e5b0df58
85 changed files with 3125 additions and 2126 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 276
INVENTREE_API_VERSION = 277
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v277 - 2024-11-01 : https://github.com/inventree/InvenTree/pull/8278
- Allow build order list to be filtered by "outstanding" (alias for "active")
v276 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8403
- Adds 'destination' field to the PurchaseOrder model and API endpoints

View File

@ -40,6 +40,9 @@ class BuildFilter(rest_filters.FilterSet):
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
# 'outstanding' is an alias for 'active' here
outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active')
def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active."""
if str2bool(value):

View File

@ -451,6 +451,10 @@ class BuildTest(BuildAPITest):
# Now, let's delete each build output individually via the API
outputs = bo.build_outputs.all()
# Assert that each output is currently in production
for output in outputs:
self.assertTrue(output.is_building)
delete_url = reverse('api-build-output-delete', kwargs={'pk': 1})
response = self.post(

View File

@ -895,7 +895,9 @@ class BaseInvenTreeSetting(models.Model):
except ValidationError as e:
raise e
except Exception:
raise ValidationError({'value': _('Invalid value')})
raise ValidationError({
'value': _('Value does not pass validation checks')
})
def validate_unique(self, exclude=None):
"""Ensure that the key:value pair is unique. In addition to the base validators, this ensures that the 'key' is unique, using a case-insensitive comparison.

View File

@ -196,7 +196,9 @@ class PurchaseOrderMixin:
"""Return the annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('supplier', 'lines')
queryset = queryset.prefetch_related(
'supplier', 'project_code', 'lines', 'responsible'
)
queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
@ -671,7 +673,9 @@ class SalesOrderMixin:
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('customer', 'lines')
queryset = queryset.prefetch_related(
'customer', 'responsible', 'project_code', 'lines'
)
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
@ -1244,7 +1248,9 @@ class ReturnOrderMixin:
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('customer')
queryset = queryset.prefetch_related(
'customer', 'lines', 'project_code', 'responsible'
)
queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)

View File

@ -105,3 +105,32 @@ class SettingsMixin:
return PluginSetting.check_all_settings(
settings_definition=self.settings, plugin=self.plugin_config()
)
def get_settings_dict(self) -> dict:
"""Return a dictionary of all settings for this plugin.
- For each setting, return <key>: <value> pair.
- If the setting is not defined, return the default value (if defined).
Returns:
dict: Dictionary of all settings for this plugin
"""
from plugin.models import PluginSetting
keys = self.settings.keys()
settings = PluginSetting.objects.filter(
plugin=self.plugin_config(), key__in=keys
)
settings_dict = {}
for setting in settings:
settings_dict[setting.key] = setting.value
# Add any missing settings
for key in keys:
if key not in settings_dict:
settings_dict[key] = self.settings[key].get('default')
return settings_dict

View File

@ -13,47 +13,6 @@ from InvenTree.exceptions import log_error
from plugin import registry
class PluginPanelList(APIView):
"""API endpoint for listing all available plugin panels."""
permission_classes = [IsAuthenticated]
serializer_class = UIPluginSerializers.PluginPanelSerializer
@extend_schema(
responses={200: UIPluginSerializers.PluginPanelSerializer(many=True)}
)
def get(self, request):
"""Show available plugin panels."""
target_model = request.query_params.get('target_model', None)
target_id = request.query_params.get('target_id', None)
panels = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom panels
for _plugin in registry.with_mixin('ui', active=True):
try:
# Allow plugins to fill this data out
plugin_panels = _plugin.get_ui_panels(
target_model, target_id, request
)
if plugin_panels and type(plugin_panels) is list:
for panel in plugin_panels:
panel['plugin'] = _plugin.slug
# TODO: Validate each panel before inserting
panels.append(panel)
except Exception:
# Custom panels could not load
# Log the error and continue
log_error(f'{_plugin.slug}.get_ui_panels')
return Response(
UIPluginSerializers.PluginPanelSerializer(panels, many=True).data
)
class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features."""
@ -71,24 +30,49 @@ class PluginUIFeatureList(APIView):
# Extract all plugins from the registry which provide custom ui features
for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out
plugin_features = _plugin.get_ui_features(
feature, request.query_params, request
)
try:
plugin_features = _plugin.get_ui_features(
feature, request.query_params, request
)
except Exception:
# Custom features could not load for this plugin
# Log the error and continue
log_error(f'{_plugin.slug}.get_ui_features')
continue
if plugin_features and type(plugin_features) is list:
for _feature in plugin_features:
features.append(_feature)
try:
# Ensure that the required fields are present
_feature['plugin_name'] = _plugin.slug
_feature['feature_type'] = str(feature)
return Response(
UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data
)
# Ensure base fields are strings
for field in ['key', 'title', 'description', 'source']:
if field in _feature:
_feature[field] = str(_feature[field])
# Add the feature to the list (serialize)
features.append(
UIPluginSerializers.PluginUIFeatureSerializer(
_feature, many=False
).data
)
except Exception:
# Custom features could not load
# Log the error and continue
log_error(f'{_plugin.slug}.get_ui_features')
continue
return Response(features)
ui_plugins_api_urls = [
path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'),
path(
'features/<str:feature>/',
PluginUIFeatureList.as_view(),
name='api-plugin-ui-feature-list',
),
)
]

View File

@ -11,43 +11,59 @@ from rest_framework.request import Request
logger = logging.getLogger('inventree')
class CustomPanel(TypedDict):
"""Type definition for a custom panel.
Attributes:
name: The name of the panel (required, used as a DOM identifier).
label: The label of the panel (required, human readable).
icon: The icon of the panel (optional, must be a valid icon identifier).
content: The content of the panel (optional, raw HTML).
context: Optional context data (dict / JSON) which will be passed to the front-end rendering function
source: The source of the panel (optional, path to a JavaScript file).
"""
name: str
label: str
icon: str
content: str
context: dict
source: str
FeatureType = Literal['template_editor', 'template_preview']
# List of supported feature types
FeatureType = Literal[
'dashboard', # Custom dashboard items
'panel', # Custom panels
'template_editor', # Custom template editor
'template_preview', # Custom template preview
]
class UIFeature(TypedDict):
"""Base type definition for a ui feature.
Attributes:
key: The key of the feature (required, must be a unique identifier)
title: The title of the feature (required, human readable)
description: The long-form description of the feature (optional, human readable)
icon: The icon of the feature (optional, must be a valid icon identifier)
feature_type: The feature type (required, see documentation for all available types)
options: Feature options (required, see documentation for all available options for each type)
source: The source of the feature (required, path to a JavaScript file).
context: Additional context data to be passed to the rendering function (optional, dict)
source: The source of the feature (required, path to a JavaScript file, with optional function name).
"""
key: str
title: str
description: str
icon: str
feature_type: FeatureType
options: dict
context: dict
source: str
class CustomPanelOptions(TypedDict):
"""Options type definition for a custom panel.
Attributes:
icon: The icon of the panel (optional, must be a valid icon identifier).
"""
class CustomDashboardItemOptions(TypedDict):
"""Options type definition for a custom dashboard item.
Attributes:
width: The minimum width of the dashboard item (integer, defaults to 2)
height: The minimum height of the dashboard item (integer, defaults to 2)
"""
width: int
height: int
class UserInterfaceMixin:
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
@ -65,48 +81,85 @@ class UserInterfaceMixin:
super().__init__()
self.add_mixin('ui', True, __class__) # type: ignore
def get_ui_panels(
self, instance_type: str, instance_id: int, request: Request, **kwargs
) -> list[CustomPanel]:
"""Return a list of custom panels to be injected into the UI.
Args:
instance_type: The type of object being viewed (e.g. 'part')
instance_id: The ID of the object being viewed (e.g. 123)
request: HTTPRequest object (including user information)
Returns:
list: A list of custom panels to be injected into the UI
- The returned list should contain a dict for each custom panel to be injected into the UI:
- The following keys can be specified:
{
'name': 'panel_name', # The name of the panel (required, must be unique)
'label': 'Panel Title', # The title of the panel (required, human readable)
'icon': 'icon-name', # Icon name (optional, must be a valid icon identifier)
'content': '<p>Panel content</p>', # HTML content to be rendered in the panel (optional)
'context': {'key': 'value'}, # Context data to be passed to the front-end rendering function (optional)
'source': 'static/plugin/panel.js', # Path to a JavaScript file to be loaded (optional)
}
- Either 'source' or 'content' must be provided
"""
# Default implementation returns an empty list
return []
def get_ui_features(
self, feature_type: FeatureType, context: dict, request: Request
self, feature_type: FeatureType, context: dict, request: Request, **kwargs
) -> list[UIFeature]:
"""Return a list of custom features to be injected into the UI.
Arguments:
feature_type: The type of feature being requested
context: Additional context data provided by the UI
context: Additional context data provided by the UI (query parameters)
request: HTTPRequest object (including user information)
Returns:
list: A list of custom UIFeature dicts to be injected into the UI
"""
feature_map = {
'dashboard': self.get_ui_dashboard_items,
'panel': self.get_ui_panels,
'template_editor': self.get_ui_template_editors,
'template_preview': self.get_ui_template_previews,
}
if feature_type in feature_map:
return feature_map[feature_type](request, context, **kwargs)
else:
logger.warning(f'Invalid feature type: {feature_type}')
return []
def get_ui_panels(
self, request: Request, context: dict, **kwargs
) -> list[UIFeature]:
"""Return a list of custom panels to be injected into the UI.
Args:
request: HTTPRequest object (including user information)
Returns:
list: A list of custom panels to be injected into the UI
"""
# Default implementation returns an empty list
return []
def get_ui_dashboard_items(
self, request: Request, context: dict, **kwargs
) -> list[UIFeature]:
"""Return a list of custom dashboard items to be injected into the UI.
Args:
request: HTTPRequest object (including user information)
Returns:
list: A list of custom dashboard items to be injected into the UI
"""
# Default implementation returns an empty list
return []
def get_ui_template_editors(
self, request: Request, context: dict, **kwargs
) -> list[UIFeature]:
"""Return a list of custom template editors to be injected into the UI.
Args:
request: HTTPRequest object (including user information)
Returns:
list: A list of custom template editors to be injected into the UI
"""
# Default implementation returns an empty list
return []
def get_ui_template_previews(
self, request: Request, context: dict, **kwargs
) -> list[UIFeature]:
"""Return a list of custom template previews to be injected into the UI.
Args:
request: HTTPRequest object (including user information)
Returns:
list: A list of custom template previews to be injected into the UI
"""
# Default implementation returns an empty list
return []

View File

@ -5,68 +5,60 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class PluginPanelSerializer(serializers.Serializer):
"""Serializer for a plugin panel."""
class Meta:
"""Meta for serializer."""
fields = [
'plugin',
'name',
'label',
# Following fields are optional
'icon',
'content',
'context',
'source',
]
# Required fields
plugin = serializers.CharField(
label=_('Plugin Key'), required=True, allow_blank=False
)
name = serializers.CharField(
label=_('Panel Name'), required=True, allow_blank=False
)
label = serializers.CharField(
label=_('Panel Title'), required=True, allow_blank=False
)
# Optional fields
icon = serializers.CharField(
label=_('Panel Icon'), required=False, allow_blank=True
)
content = serializers.CharField(
label=_('Panel Content (HTML)'), required=False, allow_blank=True
)
context = serializers.JSONField(
label=_('Panel Context (JSON)'), required=False, allow_null=True, default=None
)
source = serializers.CharField(
label=_('Panel Source (javascript)'), required=False, allow_blank=True
)
class PluginUIFeatureSerializer(serializers.Serializer):
"""Serializer for a plugin ui feature."""
class Meta:
"""Meta for serializer."""
fields = ['feature_type', 'options', 'source']
fields = [
'plugin_name',
'feature_type',
'key',
'title',
'description',
'icon',
'options',
'context',
'source',
]
# Required fields
# The name of the plugin that provides this feature
plugin_name = serializers.CharField(
label=_('Plugin Name'), required=True, allow_blank=False
)
feature_type = serializers.CharField(
label=_('Feature Type'), required=True, allow_blank=False
)
options = serializers.DictField(label=_('Feature Options'), required=True)
# Item key to be used in the UI - this should be a DOM identifier and is not user facing
key = serializers.CharField(
label=_('Feature Label'), required=True, allow_blank=False
)
# Title to be used in the UI - this is user facing (and should be human readable)
title = serializers.CharField(
label=_('Feature Title'), required=False, allow_blank=True
)
# Long-form description of the feature (optional)
description = serializers.CharField(
label=_('Feature Description'), required=False, allow_blank=True
)
# Optional icon
icon = serializers.CharField(
label=_('Feature Icon'), required=False, allow_blank=True
)
# Additional options, specific to the particular UI feature
options = serializers.DictField(label=_('Feature Options'), default=None)
# Server side context, supplied to the client side for rendering
context = serializers.DictField(label=_('Feature Context'), default=None)
source = serializers.CharField(
label=_('Feature Source (javascript)'), required=True, allow_blank=False

View File

@ -33,7 +33,60 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
plugins = registry.with_mixin('ui')
self.assertGreater(len(plugins), 0)
def test_panels(self):
def test_ui_dashboard_items(self):
"""Test that the sample UI plugin provides custom dashboard items."""
# Ensure the user has superuser status
self.user.is_superuser = True
self.user.save()
url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'dashboard'})
response = self.get(url)
self.assertEqual(len(response.data), 4)
for item in response.data:
self.assertEqual(item['plugin_name'], 'sampleui')
self.assertEqual(response.data[0]['key'], 'broken-dashboard-item')
self.assertEqual(response.data[0]['title'], 'Broken Dashboard Item')
self.assertEqual(response.data[0]['source'], '/this/does/not/exist.js')
self.assertEqual(response.data[1]['key'], 'sample-dashboard-item')
self.assertEqual(response.data[1]['title'], 'Sample Dashboard Item')
self.assertEqual(
response.data[1]['source'],
'/static/plugins/sampleui/sample_dashboard_item.js',
)
self.assertEqual(response.data[2]['key'], 'dynamic-dashboard-item')
self.assertEqual(response.data[2]['title'], 'Context Dashboard Item')
self.assertEqual(
response.data[2]['source'],
'/static/plugins/sampleui/sample_dashboard_item.js:renderContextItem',
)
self.assertEqual(response.data[3]['key'], 'admin-dashboard-item')
self.assertEqual(response.data[3]['title'], 'Admin Dashboard Item')
self.assertEqual(
response.data[3]['source'],
'/static/plugins/sampleui/admin_dashboard_item.js',
)
# Additional options and context data should be passed through to the client
self.assertDictEqual(response.data[3]['options'], {'width': 4, 'height': 2})
self.assertDictEqual(
response.data[3]['context'], {'secret-key': 'this-is-a-secret'}
)
# Remove superuser status - the 'admin-dashboard-item' should disappear
self.user.is_superuser = False
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 3)
def test_ui_panels(self):
"""Test that the sample UI plugin provides custom panels."""
from part.models import Part
@ -45,7 +98,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
_part.active = True
_part.save()
url = reverse('api-plugin-panel-list')
url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'panel'})
query_data = {'target_model': 'part', 'target_id': _part.pk}
@ -59,7 +112,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default
self.assertEqual(4, len(response.data))
self.assertEqual(3, len(response.data))
_part.active = False
_part.save()
@ -74,23 +127,27 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data)
# There should still be 3 panels
self.assertEqual(3, len(response.data))
# There should still be 2 panels
self.assertEqual(2, len(response.data))
# Check for the correct panel names
self.assertEqual(response.data[0]['name'], 'sample_panel')
self.assertIn('content', response.data[0])
self.assertNotIn('source', response.data[0])
for panel in response.data:
self.assertEqual(panel['plugin_name'], 'sampleui')
self.assertEqual(panel['feature_type'], 'panel')
self.assertEqual(response.data[1]['name'], 'broken_panel')
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
self.assertNotIn('content', response.data[1])
self.assertEqual(response.data[0]['key'], 'broken-panel')
self.assertEqual(response.data[0]['title'], 'Broken Panel')
self.assertEqual(response.data[0]['source'], '/this/does/not/exist.js')
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
self.assertEqual(response.data[1]['key'], 'dynamic-panel')
self.assertEqual(response.data[1]['title'], 'Dynamic Panel')
self.assertEqual(
response.data[2]['source'], '/static/plugins/sampleui/sample_panel.js'
response.data[1]['source'], '/static/plugins/sampleui/sample_panel.js'
)
self.assertNotIn('content', response.data[2])
ctx = response.data[1]['context']
for k in ['version', 'plugin_version', 'random', 'time']:
self.assertIn(k, ctx)
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(
@ -105,8 +162,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
# Set the setting back to True for subsequent tests
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
def test_ui_features(self):
"""Test that the sample UI plugin provides custom features."""
def test_ui_template_editors(self):
"""Test that the sample UI plugin provides template editor features."""
template_editor_url = reverse(
'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'}
)
@ -120,30 +177,39 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
'template_model': 'part',
}
# Request custom template editor information
# Request custom label template editor information
response = self.get(template_editor_url, data=query_data_label)
self.assertEqual(1, len(response.data))
data = response.data[0]
for k, v in {
'plugin_name': 'sampleui',
'key': 'sample-template-editor',
'title': 'Sample Template Editor',
'source': '/static/plugins/sampleui/sample_template.js:getTemplateEditor',
}.items():
self.assertEqual(data[k], v)
# Request custom report template editor information
response = self.get(template_editor_url, data=query_data_report)
self.assertEqual(0, len(response.data))
# Request custom report template preview information
response = self.get(template_preview_url, data=query_data_report)
self.assertEqual(1, len(response.data))
# Check for the correct feature details here
self.assertEqual(response.data[0]['feature_type'], 'template_preview')
self.assertDictEqual(
response.data[0]['options'],
{
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'icon': 'category',
},
)
self.assertEqual(
response.data[0]['source'],
'/static/plugin/sample_template.js:getTemplatePreview',
)
data = response.data[0]
for k, v in {
'plugin_name': 'sampleui',
'feature_type': 'template_preview',
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'context': None,
'source': '/static/plugins/sampleui/sample_preview.js:getTemplatePreview',
}.items():
self.assertEqual(data[k], v)
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(

View File

@ -1,30 +0,0 @@
{% 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>

View File

@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
from InvenTree.version import INVENTREE_SW_VERSION
from part.models import Part
from plugin import InvenTreePlugin
from plugin.helpers import render_template, render_text
from plugin.mixins import SettingsMixin, UserInterfaceMixin
@ -19,7 +18,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
SLUG = 'sampleui'
TITLE = 'Sample User Interface Plugin'
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
VERSION = '1.1'
VERSION = '2.0'
ADMIN_SOURCE = 'ui_settings.js'
@ -50,39 +49,22 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
},
}
def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs):
def get_ui_panels(self, request, context, **kwargs):
"""Return a list of custom panels to be injected into the UI."""
panels = []
context = context or {}
# First, add a custom panel which will appear on every type of page
# This panel will contain a simple message
content = render_text(
"""
This is a <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,
})
target_model = context.get('target_model', None)
target_id = context.get('target_id', None)
# A broken panel which tries to load a non-existent JS file
if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
if target_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
panels.append({
'name': 'broken_panel',
'label': 'Broken Panel',
'key': 'broken-panel',
'title': 'Broken Panel',
'source': '/this/does/not/exist.js',
})
@ -90,85 +72,131 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
# Note that we additionally provide some "context" data to the front-end render function
if self.get_setting('ENABLE_DYNAMIC_PANEL'):
panels.append({
'name': 'dynamic_panel',
'label': 'Dynamic Part Panel',
'key': 'dynamic-panel',
'title': 'Dynamic Panel',
'source': self.plugin_static_file('sample_panel.js'),
'icon': 'part',
'context': {
'version': INVENTREE_SW_VERSION,
'plugin_version': self.VERSION,
'random': random.randint(1, 100),
'time': time.time(),
},
'icon': 'part',
})
# Next, add a custom panel which will appear on the 'part' page
# Note that this content is rendered from a template file,
# using the django templating system
if self.get_setting('ENABLE_PART_PANELS') and instance_type == 'part':
if self.get_setting('ENABLE_PART_PANELS') and target_model == 'part':
try:
part = Part.objects.get(pk=instance_id)
part = Part.objects.get(pk=target_id)
except (Part.DoesNotExist, ValueError):
part = None
# Note: This panel will *only* be available if the part is active
if part and part.active:
content = render_template(
self, 'uidemo/custom_part_panel.html', context={'part': part}
)
panels.append({
'name': 'part_panel',
'label': 'Part Panel',
'content': content,
})
panels.append({
'key': 'part-panel',
'title': _('Part Panel'),
'source': self.plugin_static_file('sample_panel.js:renderPartPanel'),
'icon': 'part',
'context': {'part_name': part.name if part else ''},
})
# Next, add a custom panel which will appear on the 'purchaseorder' page
if (
self.get_setting('ENABLE_PURCHASE_ORDER_PANELS')
and instance_type == 'purchaseorder'
if target_model == 'purchaseorder' and self.get_setting(
'ENABLE_PURCHASE_ORDER_PANELS'
):
panels.append({
'name': 'purchase_order_panel',
'label': 'Purchase Order Panel',
'content': 'This is a custom panel which appears on the <b>Purchase Order</b> view page.',
'key': 'purchase_order_panel',
'title': 'Purchase Order Panel',
'source': self.plugin_static_file('sample_panel.js:renderPoPanel'),
})
# Admin panel - only visible to admin users
if request.user.is_superuser:
panels.append({
'key': 'admin-panel',
'title': 'Admin Panel',
'source': self.plugin_static_file(
'sample_panel.js:renderAdminOnlyPanel'
),
})
return panels
def get_ui_features(self, feature_type, context, request):
"""Return a list of custom features to be injected into the UI."""
if (
feature_type == 'template_editor'
and context.get('template_type') == 'labeltemplate'
):
return [
{
'feature_type': 'template_editor',
'options': {
'key': 'sample-template-editor',
'title': 'Sample Template Editor',
'icon': 'keywords',
},
'source': '/static/plugin/sample_template.js:getTemplateEditor',
}
]
def get_ui_dashboard_items(self, request, context, **kwargs):
"""Return a list of custom dashboard items."""
items = [
{
'key': 'broken-dashboard-item',
'title': _('Broken Dashboard Item'),
'description': _(
'This is a broken dashboard item - it will not render!'
),
'source': '/this/does/not/exist.js',
},
{
'key': 'sample-dashboard-item',
'title': _('Sample Dashboard Item'),
'description': _(
'This is a sample dashboard item. It renders a simple string of HTML content.'
),
'source': self.plugin_static_file('sample_dashboard_item.js'),
},
{
'key': 'dynamic-dashboard-item',
'title': _('Context Dashboard Item'),
'description': 'A dashboard item which passes context data from the server',
'source': self.plugin_static_file(
'sample_dashboard_item.js:renderContextItem'
),
'context': {'foo': 'bar', 'hello': 'world'},
'options': {'width': 3, 'height': 2},
},
]
if feature_type == 'template_preview':
# Admin item - only visible to users with superuser access
if request.user.is_superuser:
items.append({
'key': 'admin-dashboard-item',
'title': _('Admin Dashboard Item'),
'description': _('This is an admin-only dashboard item.'),
'source': self.plugin_static_file('admin_dashboard_item.js'),
'options': {'width': 4, 'height': 2},
'context': {'secret-key': 'this-is-a-secret'},
})
return items
def get_ui_template_editors(self, request, context, **kwargs):
"""Return a list of custom template editors."""
# If the context is a label template, return a custom template editor
if context.get('template_type') == 'labeltemplate':
return [
{
'feature_type': 'template_preview',
'options': {
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'icon': 'category',
},
'source': '/static/plugin/sample_template.js:getTemplatePreview',
'key': 'sample-template-editor',
'title': 'Sample Template Editor',
'icon': 'keywords',
'source': self.plugin_static_file(
'sample_template.js:getTemplateEditor'
),
}
]
return []
def get_ui_template_previews(self, request, context, **kwargs):
"""Return a list of custom template previews."""
return [
{
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'icon': 'category',
'source': self.plugin_static_file(
'sample_preview.js:getTemplatePreview'
),
}
]
def get_admin_context(self) -> dict:
"""Return custom context data which can be rendered in the admin panel."""
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}

View File

@ -0,0 +1,20 @@
/**
* A sample dashboard item plugin for InvenTree.
*
* - This is a *very basic* example.
* - In practice, you would want to use React / Mantine / etc to render more complex UI elements.
*/
export function renderDashboardItem(target, data) {
if (!target) {
console.error("No target provided to renderDashboardItem");
return;
}
target.innerHTML = `
<h4>Admin Item</h4>
<hr>
<p>Hello there, admin user!</p>
`;
}

View File

@ -0,0 +1,49 @@
/**
* A sample dashboard item plugin for InvenTree.
*
* - This is a *very basic* example.
* - In practice, you would want to use React / Mantine / etc to render more complex UI elements.
*/
export function renderDashboardItem(target, data) {
if (!target) {
console.error("No target provided to renderDashboardItem");
return;
}
target.innerHTML = `
<h4>Sample Dashboard Item</h4>
<hr>
<p>Hello world! This is a sample dashboard item loaded by the plugin system.</p>
`;
}
export function renderContextItem(target, data) {
if (!target) {
console.error("No target provided to renderContextItem");
return;
}
let context = data?.context ?? {};
let ctxString = '';
for (let key in context) {
ctxString += `<tr><td>${key}</td><td>${context[key]}</td></tr>`;
}
target.innerHTML = `
<h4>Sample Context Item</h4>
<hr>
<p>Hello world! This is a sample context item loaded by the plugin system.</p>
<table>
<tbody>
<tr><th>Item</th><th>Value</th></tr>
${ctxString}
</tbody>
</table>
`;
}

View File

@ -43,6 +43,55 @@ export function renderPanel(target, data) {
}
/**
* Render a panel on a Part detail page
*/
export function renderPartPanel(target, data) {
if (!target) {
console.error("No target provided to renderPartPanel");
return;
}
target.innerHTML = `
<h4>Part Detail Panel</h4>
<hr>
<p>This is a custom panel for a Part detail page</p>
`;
}
/**
* Render a panel on a PurchaseOrder detail page
*/
export function renderPoPanel(target, data) {
if (!target) {
console.error("No target provided to renderPoPanel");
return;
}
target.innerHTML = `
<h4>Order Reference: ${data.instance?.reference}</h4>
<hr>
<p>This is a custom panel for a PurchaseOrder detail page</p>
`;
}
/**
* Render a panel that is only visible to admin users
*/
export function renderAdminOnlyPanel(target, data) {
if (!target) {
console.error("No target provided to renderAdminOnlyPanel");
return;
}
target.innerHTML = `Hello Admin user! This panel is only visible to admin users.`;
}
// Dynamically hide the panel based on the provided context
export function isPanelHidden(context) {

View File

@ -0,0 +1,12 @@
export function getTemplatePreview({ featureContext, pluginContext }) {
const { ref } = featureContext;
console.log("Template preview feature was called with", featureContext, pluginContext);
featureContext.registerHandlers({
updatePreview: (...args) => {
console.log("updatePreview", args);
}
});
ref.innerHTML = "<h1>Hello world</h1>";
}

View File

@ -18,16 +18,3 @@ export function getTemplateEditor({ featureContext, pluginContext }) {
ref.innerHTML = "";
ref.appendChild(t);
}
export function getTemplatePreview({ featureContext, pluginContext }) {
const { ref } = featureContext;
console.log("Template preview feature was called with", featureContext, pluginContext);
featureContext.registerHandlers({
updatePreview: (...args) => {
console.log("updatePreview", args);
}
});
ref.innerHTML = "<h1>Hello world</h1>";
}

View File

@ -1,52 +0,0 @@
import { t } from '@lingui/macro';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { StatisticItem } from './items/DashboardItem';
import { ErrorItem } from './items/ErrorItem';
export function DashboardItemProxy({
id,
text,
url,
params,
autoupdate = true
}: Readonly<{
id: string;
text: string;
url: ApiEndpoints;
params: any;
autoupdate: boolean;
}>) {
function fetchData() {
return api
.get(`${apiUrl(url)}?search=&offset=0&limit=25`, { params: params })
.then((res) => res.data);
}
const { isLoading, error, data, isFetching } = useQuery({
queryKey: [`dash_${id}`],
queryFn: fetchData,
refetchOnWindowFocus: autoupdate
});
const [dashData, setDashData] = useState({ title: t`Title`, value: '000' });
useEffect(() => {
if (data) {
setDashData({ title: text, value: data.count });
}
}, [data]);
if (error != null) return <ErrorItem id={id} error={error} />;
return (
<div key={id}>
<StatisticItem
id={id}
data={dashData}
isLoading={isLoading || isFetching}
/>
</div>
);
}

View File

@ -12,12 +12,12 @@ export function ScanButton() {
onClick={() =>
openContextModal({
modal: 'qr',
title: t`Scan QR code`,
title: t`Scan Barcode`,
innerProps: {}
})
}
variant="transparent"
title={t`Open QR code scanner`}
title={t`Open Barcode Scanner`}
>
<IconQrcode />
</ActionIcon>

View File

@ -0,0 +1,328 @@
import { t } from '@lingui/macro';
import { Alert, Card, Center, Divider, Loader, Text } from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Layout, Responsive, WidthProvider } from 'react-grid-layout';
import { useDashboardItems } from '../../hooks/UseDashboardItems';
import { useUserState } from '../../states/UserState';
import DashboardMenu from './DashboardMenu';
import DashboardWidget, { DashboardWidgetProps } from './DashboardWidget';
import DashboardWidgetDrawer from './DashboardWidgetDrawer';
const ReactGridLayout = WidthProvider(Responsive);
/**
* Save the dashboard layout to local storage
*/
function saveDashboardLayout(layouts: any, userId: number | undefined): void {
let reducedLayouts: any = {};
// Reduce the layouts to exclude default attributes from the dataset
Object.keys(layouts).forEach((key) => {
reducedLayouts[key] = layouts[key].map((item: Layout) => {
return {
...item,
moved: item.moved ? true : undefined,
static: item.static ? true : undefined
};
});
});
const data = JSON.stringify(reducedLayouts);
if (userId) {
localStorage?.setItem(`dashboard-layout-${userId}`, data);
}
localStorage?.setItem('dashboard-layout', data);
}
/**
* Load the dashboard layout from local storage
*/
function loadDashboardLayout(
userId: number | undefined
): Record<string, Layout[]> {
let layout = userId && localStorage?.getItem(`dashboard-layout-${userId}`);
if (!layout) {
// Fallback to global layout
layout = localStorage?.getItem('dashboard-layout');
}
if (layout) {
return JSON.parse(layout);
} else {
return {};
}
}
/**
* Save the list of selected widgets to local storage
*/
function saveDashboardWidgets(
widgets: string[],
userId: number | undefined
): void {
const data = JSON.stringify(widgets);
if (userId) {
localStorage?.setItem(`dashboard-widgets-${userId}`, data);
}
localStorage?.setItem('dashboard-widgets', data);
}
/**
* Load the list of selected widgets from local storage
*/
function loadDashboardWidgets(userId: number | undefined): string[] {
let widgets = userId && localStorage?.getItem(`dashboard-widgets-${userId}`);
if (!widgets) {
// Fallback to global widget list
widgets = localStorage?.getItem('dashboard-widgets');
}
if (widgets) {
return JSON.parse(widgets);
} else {
return [];
}
}
export default function DashboardLayout({}: {}) {
const user = useUserState();
// Dashboard layout definition
const [layouts, setLayouts] = useState({});
// Dashboard widget selection
const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]);
const [editing, setEditing] = useDisclosure(false);
const [removing, setRemoving] = useDisclosure(false);
const [
widgetDrawerOpened,
{ open: openWidgetDrawer, close: closeWidgetDrawer }
] = useDisclosure(false);
const [loaded, setLoaded] = useState(false);
// Keyboard shortcut for editing the dashboard layout
useHotkeys([
[
'mod+E',
() => {
setEditing.toggle();
}
]
]);
// Load available widgets
const availableWidgets = useDashboardItems();
const widgetLabels = useMemo(() => {
return widgets.map((widget: DashboardWidgetProps) => widget.label);
}, [widgets]);
// Save the selected widgets to local storage when the selection changes
useEffect(() => {
if (loaded) {
saveDashboardWidgets(widgetLabels, user.userId());
}
}, [widgetLabels]);
/**
* Callback function to add a new widget to the dashboard
*/
const addWidget = useCallback(
(widget: string) => {
let newWidget = availableWidgets.items.find(
(wid) => wid.label === widget
);
if (newWidget) {
setWidgets([...widgets, newWidget]);
}
// Update the layouts to include the new widget (and enforce initial size)
let _layouts: any = { ...layouts };
Object.keys(_layouts).forEach((key) => {
_layouts[key] = updateLayoutForWidget(_layouts[key], widgets, true);
});
setLayouts(_layouts);
},
[availableWidgets.items, widgets, layouts]
);
/**
* Callback function to remove a widget from the dashboard
*/
const removeWidget = useCallback(
(widget: string) => {
// Remove the widget from the list
setWidgets(widgets.filter((item) => item.label !== widget));
// Remove the widget from the layout
let _layouts: any = { ...layouts };
Object.keys(_layouts).forEach((key) => {
_layouts[key] = _layouts[key].filter(
(item: Layout) => item.i !== widget
);
});
setLayouts(_layouts);
},
[widgets, layouts]
);
// When the layout is rendered, ensure that the widget attributes are observed
const updateLayoutForWidget = useCallback(
(layout: any[], widgets: any[], overrideSize: boolean) => {
return layout.map((item: Layout): Layout => {
// Find the matching widget
let widget = widgets.find(
(widget: DashboardWidgetProps) => widget.label === item.i
);
const minH = widget?.minHeight ?? 2;
const minW = widget?.minWidth ?? 1;
let w = Math.max(item.w ?? 1, minW);
let h = Math.max(item.h ?? 1, minH);
if (overrideSize) {
w = minW;
h = minH;
}
return {
...item,
w: w,
h: h,
minH: minH,
minW: minW
};
});
},
[]
);
// Rebuild layout when the widget list changes
useEffect(() => {
onLayoutChange({}, layouts);
}, [widgets]);
const onLayoutChange = useCallback(
(layout: any, newLayouts: any) => {
// Reconstruct layouts based on the widget requirements
Object.keys(newLayouts).forEach((key) => {
newLayouts[key] = updateLayoutForWidget(
newLayouts[key],
widgets,
false
);
});
if (layouts && loaded && availableWidgets.loaded) {
saveDashboardLayout(newLayouts, user.userId());
setLayouts(newLayouts);
}
},
[loaded, widgets, availableWidgets.loaded]
);
// Load the dashboard layout from local storage
useEffect(() => {
if (availableWidgets.loaded) {
const initialLayouts = loadDashboardLayout(user.userId());
const initialWidgetLabels = loadDashboardWidgets(user.userId());
setLayouts(initialLayouts);
setWidgets(
availableWidgets.items.filter((widget) =>
initialWidgetLabels.includes(widget.label)
)
);
setLoaded(true);
}
}, [availableWidgets.loaded]);
return (
<>
<DashboardWidgetDrawer
opened={widgetDrawerOpened}
onClose={closeWidgetDrawer}
onAddWidget={addWidget}
currentWidgets={widgetLabels}
/>
<DashboardMenu
onAddWidget={openWidgetDrawer}
onStartEdit={setEditing.open}
onStartRemove={setRemoving.open}
onAcceptLayout={() => {
setEditing.close();
setRemoving.close();
}}
editing={editing}
removing={removing}
/>
<Divider p="xs" />
{layouts && loaded && availableWidgets.loaded ? (
<>
{widgetLabels.length == 0 ? (
<Center>
<Card shadow="xs" padding="xl" style={{ width: '100%' }}>
<Alert
color="blue"
title={t`No Widgets Selected`}
icon={<IconInfoCircle />}
>
<Text>{t`Use the menu to add widgets to the dashboard`}</Text>
</Alert>
</Card>
</Center>
) : (
<ReactGridLayout
className="dashboard-layout"
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={64}
layouts={layouts}
onLayoutChange={onLayoutChange}
compactType={'vertical'}
isDraggable={editing}
isResizable={editing}
margin={[10, 10]}
containerPadding={[0, 0]}
resizeHandles={['ne', 'se', 'sw', 'nw']}
>
{widgets.map((item: DashboardWidgetProps) => {
return DashboardWidget({
item: item,
editing: editing,
removing: removing,
onRemove: () => {
removeWidget(item.label);
}
});
})}
</ReactGridLayout>
)}
</>
) : (
<Center>
<Loader size="xl" />
</Center>
)}
</>
);
}

View File

@ -0,0 +1,135 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Group,
Indicator,
Menu,
Paper,
Tooltip
} from '@mantine/core';
import {
IconCircleCheck,
IconDotsVertical,
IconLayout2,
IconLayoutGridAdd,
IconLayoutGridRemove
} from '@tabler/icons-react';
import { useMemo } from 'react';
import useInstanceName from '../../hooks/UseInstanceName';
import { useUserState } from '../../states/UserState';
import { StylishText } from '../items/StylishText';
/**
* A menu for editing the dashboard layout
*/
export default function DashboardMenu({
editing,
removing,
onAddWidget,
onStartEdit,
onStartRemove,
onAcceptLayout
}: {
editing: boolean;
removing: boolean;
onAddWidget: () => void;
onStartEdit: () => void;
onStartRemove: () => void;
onAcceptLayout: () => void;
}) {
const user = useUserState();
const instanceName = useInstanceName();
const title = useMemo(() => {
const username = user.username();
return (
<StylishText size="lg">{`${instanceName} - ${username}`}</StylishText>
);
}, [user, instanceName]);
return (
<Paper p="sm" shadow="xs">
<Group justify="space-between" wrap="nowrap">
{title}
<Group justify="right" wrap="nowrap">
{(editing || removing) && (
<Tooltip label={t`Accept Layout`} onClick={onAcceptLayout}>
<ActionIcon
aria-label={'dashboard-accept-layout'}
color="green"
variant="transparent"
>
<IconCircleCheck />
</ActionIcon>
</Tooltip>
)}
<Menu
shadow="md"
width={200}
openDelay={100}
closeDelay={400}
position="bottom-end"
>
<Menu.Target>
<Indicator
color="red"
position="bottom-start"
processing
disabled={!editing}
>
<ActionIcon variant="transparent" aria-label="dashboard-menu">
<IconDotsVertical />
</ActionIcon>
</Indicator>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
<Trans>Dashboard</Trans>
</Menu.Label>
{!editing && !removing && (
<Menu.Item
leftSection={<IconLayout2 color="blue" size={14} />}
onClick={onStartEdit}
>
<Trans>Edit Layout</Trans>
</Menu.Item>
)}
{!editing && !removing && (
<Menu.Item
leftSection={<IconLayoutGridAdd color="green" size={14} />}
onClick={onAddWidget}
>
<Trans>Add Widget</Trans>
</Menu.Item>
)}
{!editing && !removing && (
<Menu.Item
leftSection={<IconLayoutGridRemove color="red" size={14} />}
onClick={onStartRemove}
>
<Trans>Remove Widgets</Trans>
</Menu.Item>
)}
{(editing || removing) && (
<Menu.Item
leftSection={<IconCircleCheck color="green" size={14} />}
onClick={onAcceptLayout}
>
<Trans>Accept Layout</Trans>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</Paper>
);
}

View File

@ -0,0 +1,84 @@
import { t } from '@lingui/macro';
import { ActionIcon, Box, Group, Overlay, Paper, Tooltip } from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { Boundary } from '../Boundary';
/**
* Dashboard widget properties.
*
* @param title The title of the widget
* @param visible A function that returns whether the widget should be visible
* @param render A function that renders the widget
*/
export interface DashboardWidgetProps {
label: string;
title: string;
description: string;
minWidth?: number;
minHeight?: number;
render: () => JSX.Element;
visible?: () => boolean;
}
/**
* Wrapper for a dashboard widget.
*/
export default function DashboardWidget({
item,
editing,
removing,
onRemove
}: {
item: DashboardWidgetProps;
editing: boolean;
removing: boolean;
onRemove: () => void;
}) {
// TODO: Implement visibility check
// if (!props?.visible?.() == false) {
// return null;
// }
// TODO: Add button to remove widget (if "editing")
return (
<Paper withBorder key={item.label} shadow="sm" p="xs">
<Boundary label={`dashboard-widget-${item.label}`}>
<Box
key={`dashboard-widget-${item.label}`}
style={{
width: '100%',
height: '100%',
padding: '0px',
margin: '0px',
overflowY: 'hidden'
}}
>
{item.render()}
</Box>
{removing && (
<Overlay color="black" opacity={0.7} zIndex={1000}>
{removing && (
<Group justify="right">
<Tooltip
label={t`Remove this widget from the dashboard`}
position="bottom"
>
<ActionIcon
aria-label={`remove-dashboard-item-${item.label}`}
variant="filled"
color="red"
onClick={onRemove}
>
<IconX />
</ActionIcon>
</Tooltip>
</Group>
)}
</Overlay>
)}
</Boundary>
</Paper>
);
}

View File

@ -0,0 +1,130 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Alert,
Divider,
Drawer,
Group,
Stack,
Table,
Text,
TextInput,
Tooltip
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconBackspace, IconLayoutGridAdd } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useDashboardItems } from '../../hooks/UseDashboardItems';
import { StylishText } from '../items/StylishText';
/**
* Drawer allowing the user to add new widgets to the dashboard.
*/
export default function DashboardWidgetDrawer({
opened,
onClose,
onAddWidget,
currentWidgets
}: {
opened: boolean;
onClose: () => void;
onAddWidget: (widget: string) => void;
currentWidgets: string[];
}) {
// Load available widgets
const availableWidgets = useDashboardItems();
const [filter, setFilter] = useState<string>('');
const [filterText] = useDebouncedValue(filter, 500);
// Memoize available (not currently used) widgets
const unusedWidgets = useMemo(() => {
return (
availableWidgets.items.filter(
(widget) => !currentWidgets.includes(widget.label)
) ?? []
);
}, [availableWidgets.items, currentWidgets]);
// Filter widgets based on search text
const filteredWidgets = useMemo(() => {
let words = filterText.trim().toLowerCase().split(' ');
return unusedWidgets.filter((widget) => {
return words.every((word) =>
widget.title.toLowerCase().includes(word.trim())
);
});
}, [unusedWidgets, filterText]);
return (
<Drawer
position="right"
size="50%"
opened={opened}
onClose={onClose}
title={
<Group justify="space-between" wrap="nowrap">
<StylishText size="lg">Add Dashboard Widgets</StylishText>
</Group>
}
>
<Stack gap="xs">
<Divider />
<TextInput
aria-label="dashboard-widgets-filter-input"
placeholder={t`Filter dashboard widgets`}
value={filter}
onChange={(event) => setFilter(event.currentTarget.value)}
rightSection={
filter && (
<IconBackspace
aria-label="dashboard-widgets-filter-clear"
color="red"
onClick={() => setFilter('')}
/>
)
}
styles={{ root: { width: '100%' } }}
/>
<Table>
<Table.Tbody>
{filteredWidgets.map((widget) => (
<Table.Tr key={widget.label}>
<Table.Td>
<Tooltip
position="left"
label={t`Add this widget to the dashboard`}
>
<ActionIcon
aria-label={`add-widget-${widget.label}`}
variant="transparent"
color="green"
onClick={() => {
onAddWidget(widget.label);
}}
>
<IconLayoutGridAdd></IconLayoutGridAdd>
</ActionIcon>
</Tooltip>
</Table.Td>
<Table.Td>
<Text>{widget.title}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{widget.description}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{unusedWidgets.length === 0 && (
<Alert color="blue" title={t`No Widgets Available`}>
<Text>{t`There are no more widgets available for the dashboard`}</Text>
</Alert>
)}
</Stack>
</Drawer>
);
}

View File

@ -0,0 +1,180 @@
import { t } from '@lingui/macro';
import { ModelType } from '../../enums/ModelType';
import { DashboardWidgetProps } from './DashboardWidget';
import ColorToggleDashboardWidget from './widgets/ColorToggleWidget';
import GetStartedWidget from './widgets/GetStartedWidget';
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
import NewsWidget from './widgets/NewsWidget';
import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget';
/**
*
* @returns A list of built-in dashboard widgets which display the number of results for a particular query
*/
export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
return [
QueryCountDashboardWidget({
label: 'sub-prt',
title: t`Subscribed Parts`,
description: t`Show the number of parts which you have subscribed to`,
modelType: ModelType.part,
params: { starred: true }
}),
QueryCountDashboardWidget({
label: 'sub-cat',
title: t`Subscribed Categories`,
description: t`Show the number of part categories which you have subscribed to`,
modelType: ModelType.partcategory,
params: { starred: true }
}),
// TODO: 'latest parts'
// TODO: 'BOM waiting validation'
// TODO: 'recently updated stock'
QueryCountDashboardWidget({
title: t`Low Stock`,
label: 'low-stk',
description: t`Show the number of parts which are low on stock`,
modelType: ModelType.part,
params: { low_stock: true, active: true }
}),
// TODO: Required for build orders
QueryCountDashboardWidget({
title: t`Expired Stock Items`,
label: 'exp-stk',
description: t`Show the number of stock items which have expired`,
modelType: ModelType.stockitem,
params: { expired: true }
// TODO: Hide if expiry is disabled
}),
QueryCountDashboardWidget({
title: t`Stale Stock Items`,
label: 'stl-stk',
description: t`Show the number of stock items which are stale`,
modelType: ModelType.stockitem,
params: { stale: true }
// TODO: Hide if expiry is disabled
}),
QueryCountDashboardWidget({
title: t`Active Build Orders`,
label: 'act-bo',
description: t`Show the number of build orders which are currently active`,
modelType: ModelType.build,
params: { outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Overdue Build Orders`,
label: 'ovr-bo',
description: t`Show the number of build orders which are overdue`,
modelType: ModelType.build,
params: { overdue: true }
}),
QueryCountDashboardWidget({
title: t`Assigned Build Orders`,
label: 'asn-bo',
description: t`Show the number of build orders which are assigned to you`,
modelType: ModelType.build,
params: { assigned_to_me: true, outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Active Sales Orders`,
label: 'act-so',
description: t`Show the number of sales orders which are currently active`,
modelType: ModelType.salesorder,
params: { outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Overdue Sales Orders`,
label: 'ovr-so',
description: t`Show the number of sales orders which are overdue`,
modelType: ModelType.salesorder,
params: { overdue: true }
}),
QueryCountDashboardWidget({
title: t`Assigned Sales Orders`,
label: 'asn-so',
description: t`Show the number of sales orders which are assigned to you`,
modelType: ModelType.salesorder,
params: { assigned_to_me: true, outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Active Purchase Orders`,
label: 'act-po',
description: t`Show the number of purchase orders which are currently active`,
modelType: ModelType.purchaseorder,
params: { outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Overdue Purchase Orders`,
label: 'ovr-po',
description: t`Show the number of purchase orders which are overdue`,
modelType: ModelType.purchaseorder,
params: { overdue: true }
}),
QueryCountDashboardWidget({
title: t`Assigned Purchase Orders`,
label: 'asn-po',
description: t`Show the number of purchase orders which are assigned to you`,
modelType: ModelType.purchaseorder,
params: { assigned_to_me: true, outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Active Return Orders`,
label: 'act-ro',
description: t`Show the number of return orders which are currently active`,
modelType: ModelType.returnorder,
params: { outstanding: true }
}),
QueryCountDashboardWidget({
title: t`Overdue Return Orders`,
label: 'ovr-ro',
description: t`Show the number of return orders which are overdue`,
modelType: ModelType.returnorder,
params: { overdue: true }
}),
QueryCountDashboardWidget({
title: t`Assigned Return Orders`,
label: 'asn-ro',
description: t`Show the number of return orders which are assigned to you`,
modelType: ModelType.returnorder,
params: { assigned_to_me: true, outstanding: true }
})
];
}
export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
return [
{
label: 'gstart',
title: t`Getting Started`,
description: t`Getting started with InvenTree`,
minWidth: 5,
minHeight: 4,
render: () => <GetStartedWidget />
},
{
label: 'news',
title: t`News Updates`,
description: t`The latest news from InvenTree`,
minWidth: 5,
minHeight: 4,
render: () => <NewsWidget />
}
];
}
export function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()];
}
/**
*
* @returns A list of built-in dashboard widgets
*/
export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
return [
...BuiltinQueryCountWidgets(),
...BuiltinGettingStartedWidgets(),
...BuiltinSettingsWidgets()
];
}

View File

@ -0,0 +1,28 @@
import { t } from '@lingui/macro';
import { Group } from '@mantine/core';
import { ColorToggle } from '../../items/ColorToggle';
import { StylishText } from '../../items/StylishText';
import { DashboardWidgetProps } from '../DashboardWidget';
function ColorToggleWidget(title: string) {
return (
<Group justify="space-between" wrap="nowrap">
<StylishText size="lg">{title}</StylishText>
<ColorToggle />
</Group>
);
}
export default function ColorToggleDashboardWidget(): DashboardWidgetProps {
const title = t`Change Color Mode`;
return {
label: 'clr',
title: title,
description: t`Change the color mode of the user interface`,
minHeight: 1,
minWidth: 2,
render: () => ColorToggleWidget(title)
};
}

View File

@ -0,0 +1,19 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import { useMemo } from 'react';
import { DocumentationLinks } from '../../../defaults/links';
import { GettingStartedCarousel } from '../../items/GettingStartedCarousel';
import { MenuLinkItem } from '../../items/MenuLinks';
import { StylishText } from '../../items/StylishText';
export default function GetStartedWidget() {
const docLinks: MenuLinkItem[] = useMemo(() => DocumentationLinks(), []);
return (
<Stack>
<StylishText size="xl">{t`Getting Started`}</StylishText>
<GettingStartedCarousel items={docLinks} />
</Stack>
);
}

View File

@ -0,0 +1,28 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import { LanguageSelect } from '../../items/LanguageSelect';
import { StylishText } from '../../items/StylishText';
import { DashboardWidgetProps } from '../DashboardWidget';
function LanguageSelectWidget(title: string) {
return (
<Stack gap="xs">
<StylishText size="lg">{title}</StylishText>
<LanguageSelect width={140} />
</Stack>
);
}
export default function LanguageSelectDashboardWidget(): DashboardWidgetProps {
const title = t`Change Language`;
return {
label: 'lngsel',
title: title,
description: t`Change the language of the user interface`,
minHeight: 1,
minWidth: 2,
render: () => LanguageSelectWidget(title)
};
}

View File

@ -0,0 +1,143 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Alert,
Anchor,
Container,
Group,
ScrollArea,
Stack,
Table,
Text,
Tooltip
} from '@mantine/core';
import { IconMailCheck } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { api } from '../../../App';
import { formatDate } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { StylishText } from '../../items/StylishText';
/**
* Render a link to an external news item
*/
function NewsLink({ item }: { item: any }) {
let link: string = item.link;
if (link && link.startsWith('/')) {
link = 'https://inventree.org' + link;
}
if (link) {
return (
<Anchor href={link} target="_blank">
{item.title}
</Anchor>
);
} else {
return <Text>{item.title}</Text>;
}
}
function NewsItem({
item,
onMarkRead
}: {
item: any;
onMarkRead: (id: number) => void;
}) {
const date: string = item.published?.split(' ')[0] ?? '';
return (
<Table.Tr>
<Table.Td>{formatDate(date)}</Table.Td>
<Table.Td>
<NewsLink item={item} />
</Table.Td>
<Table.Td>
<Tooltip label={t`Mark as read`}>
<ActionIcon
size="sm"
color="green"
variant="transparent"
onClick={() => onMarkRead(item.pk)}
>
<IconMailCheck />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
}
/**
* A widget which displays a list of news items on the dashboard
*/
export default function NewsWidget() {
const user = useUserState();
const newsItems = useQuery({
queryKey: ['news-items'],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.news), {
params: {
read: false
}
})
.then((response: any) => response.data)
.catch(() => [])
});
const markRead = useCallback(
(id: number) => {
api
.patch(apiUrl(ApiEndpoints.news, id), {
read: true
})
.then(() => {
newsItems.refetch();
});
},
[newsItems]
);
const hasNews = useMemo(
() => (newsItems.data?.length ?? 0) > 0,
[newsItems.data]
);
if (!user.isSuperuser()) {
return (
<Alert color="red" title={t`Requires Superuser`}>
<Text>{t`This widget requires superuser permissions`}</Text>
</Alert>
);
}
return (
<Stack>
<StylishText size="xl">{t`News Updates`}</StylishText>
<ScrollArea h={400}>
<Container>
<Table>
<Table.Tbody>
{hasNews ? (
newsItems.data?.map((item: any) => (
<NewsItem key={item.pk} item={item} onMarkRead={markRead} />
))
) : (
<Alert color="green" title={t`No News`}>
<Text>{t`There are no unread news items`}</Text>
</Alert>
)}
</Table.Tbody>
</Table>
</Container>
</ScrollArea>
</Stack>
);
}

View File

@ -0,0 +1,131 @@
import {
ActionIcon,
Card,
Group,
Loader,
Skeleton,
Space,
Stack,
Text
} from '@mantine/core';
import { IconExternalLink } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { on } from 'events';
import { ReactNode, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../../App';
import { ModelType } from '../../../enums/ModelType';
import { identifierString } from '../../../functions/conversion';
import { InvenTreeIcon, InvenTreeIconType } from '../../../functions/icons';
import { navigateToLink } from '../../../functions/navigation';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { StylishText } from '../../items/StylishText';
import { ModelInformationDict } from '../../render/ModelType';
import { DashboardWidgetProps } from '../DashboardWidget';
/**
* A simple dashboard widget for displaying the number of results for a particular query
*/
function QueryCountWidget({
modelType,
title,
icon,
params
}: {
modelType: ModelType;
title: string;
icon?: InvenTreeIconType;
params: any;
}): ReactNode {
const user = useUserState();
const navigate = useNavigate();
const modelProperties = ModelInformationDict[modelType];
const query = useQuery({
queryKey: ['dashboard-query-count', modelType, params],
enabled: user.hasViewPermission(modelType),
refetchOnMount: true,
queryFn: () => {
return api
.get(apiUrl(modelProperties.api_endpoint), {
params: {
...params,
limit: 1
}
})
.then((res) => res.data);
}
});
const onFollowLink = useCallback(
(event: any) => {
if (modelProperties.url_overview) {
let url = modelProperties.url_overview;
if (params) {
url += '?';
for (const key in params) {
url += `${key}=${params[key]}&`;
}
}
navigateToLink(url, navigate, event);
}
},
[modelProperties, params]
);
// TODO: Improve visual styling
return (
<Group gap="xs" wrap="nowrap">
<InvenTreeIcon icon={icon ?? modelProperties.icon} />
<Group gap="xs" wrap="nowrap" justify="space-between">
<StylishText size="md">{title}</StylishText>
<Group gap="xs" wrap="nowrap" justify="right">
{query.isFetching ? (
<Loader size="sm" />
) : (
<StylishText size="sm">{query.data?.count ?? '-'}</StylishText>
)}
{modelProperties?.url_overview && (
<ActionIcon size="sm" variant="transparent" onClick={onFollowLink}>
<IconExternalLink />
</ActionIcon>
)}
</Group>
</Group>
</Group>
);
}
/**
* Construct a dashboard widget descriptor, which displays the number of results for a particular query
*/
export default function QueryCountDashboardWidget({
label,
title,
description,
modelType,
params
}: {
label: string;
title: string;
description: string;
modelType: ModelType;
params: any;
}): DashboardWidgetProps {
return {
label: label,
title: title,
description: description,
minWidth: 2,
minHeight: 1,
render: () => (
<QueryCountWidget modelType={modelType} title={title} params={params} />
)
};
}

View File

@ -34,6 +34,7 @@ import { ModelType } from '../../../enums/ModelType';
import { TablerIconType } from '../../../functions/icons';
import { apiUrl } from '../../../states/ApiState';
import { TemplateI } from '../../../tables/settings/TemplateTable';
import { Boundary } from '../../Boundary';
import { SplitButton } from '../../buttons/SplitButton';
import { StandaloneField } from '../../forms/StandaloneField';
import { ModelInformationDict } from '../../render/ModelType';
@ -41,21 +42,25 @@ import { ModelInformationDict } from '../../render/ModelType';
type EditorProps = {
template: TemplateI;
};
type EditorRef = {
setCode: (code: string) => void | Promise<void>;
getCode: () => (string | undefined) | Promise<string | undefined>;
};
export type EditorComponent = React.ForwardRefExoticComponent<
EditorProps & React.RefAttributes<EditorRef>
>;
export type Editor = {
key: string;
name: string;
icon: TablerIconType;
icon?: TablerIconType;
component: EditorComponent;
};
type PreviewAreaProps = {};
export type PreviewAreaRef = {
updatePreview: (
code: string,
@ -64,9 +69,11 @@ export type PreviewAreaRef = {
templateEditorProps: TemplateEditorProps
) => void | Promise<void>;
};
export type PreviewAreaComponent = React.ForwardRefExoticComponent<
PreviewAreaProps & React.RefAttributes<PreviewAreaRef>
>;
export type PreviewArea = {
key: string;
name: string;
@ -247,165 +254,171 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
}, [previewApiUrl, templateFilters]);
return (
<Stack style={{ height: '100%', flex: '1' }}>
<Split style={{ gap: '10px' }}>
<Tabs
value={editorValue}
onChange={async (v) => {
codeRef.current = await getCodeFromEditor();
setEditorValue(v);
}}
keepMounted={false}
style={{
minWidth: '300px',
flex: '1',
display: 'flex',
flexDirection: 'column'
}}
>
<Tabs.List>
{editors.map((Editor) => (
<Tabs.Tab
key={Editor.key}
value={Editor.key}
leftSection={<Editor.icon size="0.8rem" />}
>
{Editor.name}
</Tabs.Tab>
))}
<Group justify="right" style={{ flex: '1' }} wrap="nowrap">
<SplitButton
loading={isPreviewLoading}
defaultSelected="preview_save"
name="preview-options"
options={[
{
key: 'preview',
name: t`Reload preview`,
tooltip: t`Use the currently stored template from the server`,
icon: IconRefresh,
onClick: () => updatePreview(true, false),
disabled: !previewItem || isPreviewLoading
},
{
key: 'preview_save',
name: t`Save & Reload Preview`,
tooltip: t`Save the current template and reload the preview`,
icon: IconDeviceFloppy,
onClick: () => updatePreview(hasSaveConfirmed),
disabled: !previewItem || isPreviewLoading
}
]}
/>
</Group>
</Tabs.List>
{editors.map((Editor) => (
<Tabs.Panel
key={Editor.key}
value={Editor.key}
style={{
display: 'flex',
flex: editorValue === Editor.key ? 1 : 0
}}
>
{/* @ts-ignore-next-line */}
<Editor.component ref={editorRef} template={props.template} />
</Tabs.Panel>
))}
</Tabs>
<Tabs
value={previewValue}
onChange={setPreviewValue}
keepMounted={false}
style={{
minWidth: '200px',
display: 'flex',
flexDirection: 'column'
}}
>
<Tabs.List>
{previewAreas.map((PreviewArea) => (
<Tabs.Tab
key={PreviewArea.key}
value={PreviewArea.key}
leftSection={<PreviewArea.icon size="0.8rem" />}
>
{PreviewArea.name}
</Tabs.Tab>
))}
</Tabs.List>
<div
<Boundary label="TemplateEditor">
<Stack style={{ height: '100%', flex: '1' }}>
<Split style={{ gap: '10px' }}>
<Tabs
value={editorValue}
onChange={async (v) => {
codeRef.current = await getCodeFromEditor();
setEditorValue(v);
}}
keepMounted={false}
style={{
minWidth: '100%',
paddingBottom: '10px',
paddingTop: '10px'
minWidth: '300px',
flex: '1',
display: 'flex',
flexDirection: 'column'
}}
>
<StandaloneField
fieldDefinition={{
field_type: 'related field',
api_url: apiUrl(previewApiUrl),
description: '',
label: t`Select instance to preview`,
model: template.model_type,
value: previewItem,
filters: templateFilters,
onValueChange: (value) => setPreviewItem(value)
}}
/>
</div>
<Tabs.List>
{editors.map((Editor, index) => {
return (
<Tabs.Tab
key={Editor.key}
value={Editor.key}
leftSection={Editor.icon && <Editor.icon size="0.8rem" />}
>
{Editor.name}
</Tabs.Tab>
);
})}
{previewAreas.map((PreviewArea) => (
<Tabs.Panel
key={PreviewArea.key}
value={PreviewArea.key}
style={{
display: 'flex',
flex: previewValue === PreviewArea.key ? 1 : 0
}}
>
<div
<Group justify="right" style={{ flex: '1' }} wrap="nowrap">
<SplitButton
loading={isPreviewLoading}
defaultSelected="preview_save"
name="preview-options"
options={[
{
key: 'preview',
name: t`Reload preview`,
tooltip: t`Use the currently stored template from the server`,
icon: IconRefresh,
onClick: () => updatePreview(true, false),
disabled: !previewItem || isPreviewLoading
},
{
key: 'preview_save',
name: t`Save & Reload Preview`,
tooltip: t`Save the current template and reload the preview`,
icon: IconDeviceFloppy,
onClick: () => updatePreview(hasSaveConfirmed),
disabled: !previewItem || isPreviewLoading
}
]}
/>
</Group>
</Tabs.List>
{editors.map((Editor) => (
<Tabs.Panel
key={Editor.key}
value={Editor.key}
style={{
height: '100%',
position: 'relative',
display: 'flex',
flex: '1'
flex: editorValue === Editor.key ? 1 : 0
}}
>
{/* @ts-ignore-next-line */}
<PreviewArea.component ref={previewRef} />
<Editor.component ref={editorRef} template={props.template} />
</Tabs.Panel>
))}
</Tabs>
{errorOverlay && (
<Overlay color="red" center blur={0.2}>
<CloseButton
onClick={() => setErrorOverlay(null)}
style={{
position: 'absolute',
top: '10px',
right: '10px',
color: '#fff'
}}
variant="filled"
/>
<Alert
color="red"
icon={<IconExclamationCircle />}
title={t`Error rendering template`}
mx="10px"
>
<Code>{errorOverlay}</Code>
</Alert>
</Overlay>
)}
</div>
</Tabs.Panel>
))}
</Tabs>
</Split>
</Stack>
<Tabs
value={previewValue}
onChange={setPreviewValue}
keepMounted={false}
style={{
minWidth: '200px',
display: 'flex',
flexDirection: 'column'
}}
>
<Tabs.List>
{previewAreas.map((PreviewArea) => (
<Tabs.Tab
key={PreviewArea.key}
value={PreviewArea.key}
leftSection={
PreviewArea.icon && <PreviewArea.icon size="0.8rem" />
}
>
{PreviewArea.name}
</Tabs.Tab>
))}
</Tabs.List>
<div
style={{
minWidth: '100%',
paddingBottom: '10px',
paddingTop: '10px'
}}
>
<StandaloneField
fieldDefinition={{
field_type: 'related field',
api_url: apiUrl(previewApiUrl),
description: '',
label: t`Select instance to preview`,
model: template.model_type,
value: previewItem,
filters: templateFilters,
onValueChange: (value) => setPreviewItem(value)
}}
/>
</div>
{previewAreas.map((PreviewArea) => (
<Tabs.Panel
key={PreviewArea.key}
value={PreviewArea.key}
style={{
display: 'flex',
flex: previewValue === PreviewArea.key ? 1 : 0
}}
>
<div
style={{
height: '100%',
position: 'relative',
display: 'flex',
flex: '1'
}}
>
{/* @ts-ignore-next-line */}
<PreviewArea.component ref={previewRef} />
{errorOverlay && (
<Overlay color="red" center blur={0.2}>
<CloseButton
onClick={() => setErrorOverlay(null)}
style={{
position: 'absolute',
top: '10px',
right: '10px',
color: '#fff'
}}
variant="filled"
/>
<Alert
color="red"
icon={<IconExclamationCircle />}
title={t`Error rendering template`}
mx="10px"
>
<Code>{errorOverlay}</Code>
</Alert>
</Overlay>
)}
</div>
</Tabs.Panel>
))}
</Tabs>
</Split>
</Stack>
</Boundary>
);
}

View File

@ -1,85 +0,0 @@
import { Anchor, Group, SimpleGrid, Text } from '@mantine/core';
import { DocTooltip } from './DocTooltip';
import { PlaceholderPill } from './Placeholder';
interface DocumentationLinkBase {
id: string;
title: string | JSX.Element;
description: string | JSX.Element;
placeholder?: boolean;
}
interface DocumentationLinkItemLink extends DocumentationLinkBase {
link: string;
action?: never;
}
interface DocumentationLinkItemAction extends DocumentationLinkBase {
link?: never;
action: () => void;
}
export type DocumentationLinkItem =
| DocumentationLinkItemLink
| DocumentationLinkItemAction;
export function DocumentationLinks({
links
}: Readonly<{
links: DocumentationLinkItem[];
}>) {
const DocumentationLinkRenderer = ({
link
}: {
link: DocumentationLinkItem;
}) => {
const content = (
<Text size="sm" fw={500}>
{link.title}
</Text>
);
const Linker = ({ children }: { children: any }) => {
if (link.link)
return (
<Anchor href={link.link} key={link.id}>
{children}
</Anchor>
);
if (link.action)
return (
<Anchor component="button" type="button" onClick={link.action}>
{children}
</Anchor>
);
console.log('Neither link nor action found for link:', link);
return children;
};
return (
<Linker>
{link.placeholder ? (
<Group>
{content}
<PlaceholderPill />
</Group>
) : (
content
)}
</Linker>
);
};
return (
<SimpleGrid cols={2} spacing={0}>
{links.map((link) => (
<DocTooltip key={link.id} text={link.description}>
<DocumentationLinkRenderer link={link} />
</DocTooltip>
))}
</SimpleGrid>
);
}

View File

@ -1,23 +1,16 @@
import { Trans } from '@lingui/macro';
import { Carousel } from '@mantine/carousel';
import { Anchor, Button, Paper, Text, Title } from '@mantine/core';
import { Anchor, Button, Paper, Text } from '@mantine/core';
import { DocumentationLinkItem } from './DocumentationLinks';
import * as classes from './GettingStartedCarousel.css';
import { PlaceholderPill } from './Placeholder';
import { MenuLinkItem } from './MenuLinks';
import { StylishText } from './StylishText';
function StartedCard({
title,
description,
link,
placeholder
}: DocumentationLinkItem) {
function StartedCard({ title, description, link }: MenuLinkItem) {
return (
<Paper shadow="md" p="xl" radius="md" className={classes.card}>
<div>
<Title order={3} className={classes.title}>
{title} {placeholder && <PlaceholderPill />}
</Title>
<StylishText size="md">{title}</StylishText>
<Text size="sm" className={classes.category} lineClamp={2}>
{description}
</Text>
@ -34,7 +27,7 @@ function StartedCard({
export function GettingStartedCarousel({
items
}: Readonly<{
items: DocumentationLinkItem[];
items: MenuLinkItem[];
}>) {
const slides = items.map((item) => (
<Carousel.Slide key={item.id}>

View File

@ -1,75 +1,109 @@
import { SimpleGrid, Text, UnstyledButton } from '@mantine/core';
import React from 'react';
import { Link } from 'react-router-dom';
import {
Anchor,
Divider,
Group,
SimpleGrid,
Stack,
Text,
Tooltip,
UnstyledButton
} from '@mantine/core';
import { IconLink } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import * as classes from '../../main.css';
import { DocTooltip } from './DocTooltip';
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
import { navigateToLink } from '../../functions/navigation';
import { StylishText } from './StylishText';
export interface MenuLinkItem {
id: string;
text: string | JSX.Element;
link: string;
highlight?: boolean;
doctext?: string | JSX.Element;
docdetail?: string | JSX.Element;
doclink?: string;
docchildren?: React.ReactNode;
}
export type menuItemsCollection = {
[key: string]: MenuLinkItem;
};
function ConditionalDocTooltip({
item,
children
}: Readonly<{
item: MenuLinkItem;
children: React.ReactNode;
}>) {
if (item.doctext !== undefined) {
return (
<DocTooltip
key={item.id}
text={item.doctext}
detail={item?.docdetail}
link={item?.doclink}
docchildren={item?.docchildren}
>
{children}
</DocTooltip>
);
}
return <>{children}</>;
title: string | JSX.Element;
description?: string;
icon?: InvenTreeIconType;
action?: () => void;
link?: string;
external?: boolean;
hidden?: boolean;
}
export function MenuLinks({
title,
links,
highlighted = false
beforeClick
}: Readonly<{
title: string;
links: MenuLinkItem[];
highlighted?: boolean;
beforeClick?: () => void;
}>) {
const filteredLinks = links.filter(
(item) => !highlighted || item.highlight === true
const navigate = useNavigate();
// Filter out any hidden links
const visibleLinks = useMemo(
() => links.filter((item) => !item.hidden),
[links]
);
if (visibleLinks.length == 0) {
return null;
}
return (
<SimpleGrid cols={2} spacing={0}>
{filteredLinks.map((item) => (
<ConditionalDocTooltip item={item} key={item.id}>
<UnstyledButton
className={classes.subLink}
component={Link}
to={item.link}
p={0}
>
<Text size="sm" fw={500}>
{item.text}
</Text>
</UnstyledButton>
</ConditionalDocTooltip>
))}
</SimpleGrid>
<>
<Stack gap="xs">
<Divider />
<StylishText size="md">{title}</StylishText>
<Divider />
<SimpleGrid cols={2} spacing={0} p={3}>
{visibleLinks.map((item) => (
<Tooltip
key={`menu-link-tooltip-${item.id}`}
label={item.description}
hidden={!item.description}
>
{item.link && item.external ? (
<Anchor href={item.link}>
<Group wrap="nowrap">
{item.external && (
<InvenTreeIcon
icon={item.icon ?? 'link'}
iconProps={{ size: '14' }}
/>
)}
<Text fw={500} p={5}>
{item.title}
</Text>
</Group>
</Anchor>
) : (
<UnstyledButton
onClick={(event) => {
if (item.link) {
beforeClick?.();
navigateToLink(item.link, navigate, event);
} else if (item.action) {
beforeClick?.();
item.action();
}
}}
>
<Group wrap="nowrap">
{item.icon && (
<InvenTreeIcon
icon={item.icon}
iconProps={{ size: '14' }}
/>
)}
<Text fw={500} p={5}>
{item.title}
</Text>
</Group>
</UnstyledButton>
)}
</Tooltip>
))}
</SimpleGrid>
</Stack>
</>
);
}

View File

@ -1,28 +1,11 @@
import { Anchor, Container, Group } from '@mantine/core';
import { footerLinks } from '../../defaults/links';
import * as classes from '../../main.css';
import { InvenTreeLogoHomeButton } from '../items/InvenTreeLogo';
export function Footer() {
const items = footerLinks.map((link) => (
<Anchor<'a'>
c="dimmed"
key={link.key}
href={link.link}
onClick={(event) => event.preventDefault()}
size="sm"
>
{link.label}
</Anchor>
));
return (
<div className={classes.layoutFooter}>
<Container className={classes.layoutFooterInner} size={'100%'}>
<InvenTreeLogoHomeButton />
<Group className={classes.layoutFooterLinks}>{items}</Group>
</Container>
{
// Placeholder for footer links
}
</div>
);
}

View File

@ -12,6 +12,7 @@ import { navigateToLink } from '../../functions/navigation';
import * as classes from '../../main.css';
import { apiUrl } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { ScanButton } from '../buttons/ScanButton';
import { SpotlightButton } from '../buttons/SpotlightButton';
@ -42,6 +43,8 @@ export function Header() {
const [notificationCount, setNotificationCount] = useState<number>(0);
const globalSettings = useGlobalSettingsState();
// Fetch number of notifications for the current user
const notifications = useQuery({
queryKey: ['notification-count'],
@ -111,7 +114,7 @@ export function Header() {
<IconSearch />
</ActionIcon>
<SpotlightButton />
<ScanButton />
{globalSettings.isSet('BARCODE_ENABLE') && <ScanButton />}
<Indicator
radius="lg"
size="18"

View File

@ -1,117 +1,15 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Anchor,
Button,
Divider,
Group,
HoverCard,
Skeleton,
Text,
UnstyledButton,
useMantineColorScheme
} from '@mantine/core';
import { IconLayoutSidebar } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { UnstyledButton } from '@mantine/core';
import { menuItems } from '../../defaults/menuItems';
import * as classes from '../../main.css';
import { useServerApiState } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { vars } from '../../theme';
import { InvenTreeLogo } from '../items/InvenTreeLogo';
import { MenuLinks } from '../items/MenuLinks';
const onlyItems = Object.values(menuItems);
export function NavHoverMenu({
openDrawer: openDrawer
}: Readonly<{
openDrawer: () => void;
}>) {
const [hostKey, hostList] = useLocalState((state) => [
state.hostKey,
state.hostList
]);
const [servername] = useServerApiState((state) => [state.server.instance]);
const [instanceName, setInstanceName] = useState<string>();
const { colorScheme } = useMantineColorScheme();
useEffect(() => {
if (hostKey && hostList[hostKey]) {
setInstanceName(hostList[hostKey]?.name);
}
}, [hostKey]);
return (
<HoverCard
width={600}
openDelay={300}
position="bottom"
shadow="md"
withinPortal
>
<HoverCard.Target>
<UnstyledButton onClick={() => openDrawer()} aria-label="Homenav">
<InvenTreeLogo />
</UnstyledButton>
</HoverCard.Target>
<HoverCard.Dropdown style={{ overflow: 'hidden' }}>
<Group justify="space-between" px="md">
<ActionIcon
onClick={openDrawer}
onMouseOver={openDrawer}
title={t`Open Navigation`}
variant="default"
>
<IconLayoutSidebar />
</ActionIcon>
<Group gap={'xs'}>
{instanceName ? (
instanceName
) : (
<Skeleton height={20} width={40} radius={vars.radiusDefault} />
)}{' '}
|{' '}
{servername ? (
servername
) : (
<Skeleton height={20} width={40} radius={vars.radiusDefault} />
)}
</Group>
<Anchor href="#" fz="xs" onClick={openDrawer}>
<Trans>View all</Trans>
</Anchor>
</Group>
<Divider
my="sm"
mx="-md"
color={
colorScheme === 'dark' ? vars.colors.dark[5] : vars.colors.gray[1]
}
/>
<MenuLinks links={onlyItems} highlighted={true} />
<div className={classes.headerDropdownFooter}>
<Group justify="space-between">
<div>
<Text fw={500} fz="sm">
<Trans>Get started</Trans>
</Text>
<Text size="xs" c="dimmed">
<Trans>
Overview over high-level objects, functions and possible
usecases.
</Trans>
</Text>
</div>
<Button variant="default">
<Trans>Get started</Trans>
</Button>
</Group>
</div>
</HoverCard.Dropdown>
</HoverCard>
<UnstyledButton onClick={() => openDrawer()} aria-label="navigation-menu">
<InvenTreeLogo />
</UnstyledButton>
);
}

View File

@ -3,22 +3,26 @@ import {
Container,
Drawer,
Flex,
Group,
ScrollArea,
Space,
Title
Space
} from '@mantine/core';
import { useViewportSize } from '@mantine/hooks';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { aboutLinks, navDocLinks } from '../../defaults/links';
import { menuItems } from '../../defaults/menuItems';
import { AboutLinks, DocumentationLinks } from '../../defaults/links';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import useInstanceName from '../../hooks/UseInstanceName';
import * as classes from '../../main.css';
import { DocumentationLinks } from '../items/DocumentationLinks';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { InvenTreeLogo } from '../items/InvenTreeLogo';
import { MenuLinkItem, MenuLinks } from '../items/MenuLinks';
import { StylishText } from '../items/StylishText';
// TODO @matmair #1: implement plugin loading and menu item generation see #5269
const plugins: MenuLinkItem[] = [];
const onlyItems = Object.values(menuItems);
export function NavigationDrawer({
opened,
@ -31,39 +35,163 @@ export function NavigationDrawer({
<Drawer
opened={opened}
onClose={close}
overlayProps={{ opacity: 0.5, blur: 4 }}
size="lg"
withCloseButton={false}
classNames={{
body: classes.navigationDrawer
}}
>
<DrawerContent />
<DrawerContent closeFunc={close} />
</Drawer>
);
}
function DrawerContent() {
function DrawerContent({ closeFunc }: { closeFunc?: () => void }) {
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const [scrollHeight, setScrollHeight] = useState(0);
const ref = useRef(null);
const { height } = useViewportSize();
const title = useInstanceName();
// update scroll height when viewport size changes
useEffect(() => {
if (ref.current == null) return;
setScrollHeight(height - ref.current['clientHeight'] - 65);
});
// Construct menu items
const menuItemsNavigate: MenuLinkItem[] = useMemo(() => {
return [
{
id: 'home',
title: t`Dashboard`,
link: '/',
icon: 'dashboard'
},
{
id: 'parts',
title: t`Parts`,
hidden: !user.hasViewPermission(ModelType.part),
link: '/part',
icon: 'part'
},
{
id: 'stock',
title: t`Stock`,
link: '/stock',
hidden: !user.hasViewPermission(ModelType.stockitem),
icon: 'stock'
},
{
id: 'build',
title: t`Manufacturing`,
link: '/manufacturing/',
hidden: !user.hasViewRole(UserRoles.build),
icon: 'build'
},
{
id: 'purchasing',
title: t`Purchasing`,
link: '/purchasing/',
hidden: !user.hasViewRole(UserRoles.purchase_order),
icon: 'purchase_orders'
},
{
id: 'sales',
title: t`Sales`,
link: '/sales/',
hidden: !user.hasViewRole(UserRoles.sales_order),
icon: 'sales_orders'
}
];
}, [user]);
const menuItemsAction: MenuLinkItem[] = useMemo(() => {
return [
{
id: 'barcode',
title: t`Scan Barcode`,
link: '/scan',
icon: 'barcode',
hidden: !globalSettings.isSet('BARCODE_ENABLE')
}
];
}, [user, globalSettings]);
const menuItemsSettings: MenuLinkItem[] = useMemo(() => {
return [
{
id: 'notifications',
title: t`Notifications`,
link: '/notifications',
icon: 'notification'
},
{
id: 'user-settings',
title: t`User Settings`,
link: '/settings/user',
icon: 'user'
},
{
id: 'system-settings',
title: t`System Settings`,
link: '/settings/system',
icon: 'system',
hidden: !user.isStaff()
},
{
id: 'admin-center',
title: t`Admin Center`,
link: '/settings/admin',
icon: 'admin',
hidden: !user.isStaff()
}
];
}, [user]);
const menuItemsDocumentation: MenuLinkItem[] = useMemo(
() => DocumentationLinks(),
[]
);
const menuItemsAbout: MenuLinkItem[] = useMemo(() => AboutLinks(), []);
return (
<Flex direction="column" mih="100vh" p={16}>
<Title order={3}>{t`Navigation`}</Title>
<Group wrap="nowrap">
<InvenTreeLogo />
<StylishText size="xl">{title}</StylishText>
</Group>
<Space h="xs" />
<Container className={classes.layoutContent} p={0}>
<ScrollArea h={scrollHeight} type="always" offsetScrollbars>
<Title order={5}>{t`Pages`}</Title>
<MenuLinks links={onlyItems} />
<MenuLinks
title={t`Navigation`}
links={menuItemsNavigate}
beforeClick={closeFunc}
/>
<MenuLinks
title={t`Settings`}
links={menuItemsSettings}
beforeClick={closeFunc}
/>
<MenuLinks
title={t`Actions`}
links={menuItemsAction}
beforeClick={closeFunc}
/>
<Space h="md" />
{plugins.length > 0 ? (
<>
<Title order={5}>{t`Plugins`}</Title>
<MenuLinks links={plugins} />
<MenuLinks
title={t`Plugins`}
links={plugins}
beforeClick={closeFunc}
/>
</>
) : (
<></>
@ -72,11 +200,17 @@ function DrawerContent() {
</Container>
<div ref={ref}>
<Space h="md" />
<Title order={5}>{t`Documentation`}</Title>
<DocumentationLinks links={navDocLinks} />
<MenuLinks
title={t`Documentation`}
links={menuItemsDocumentation}
beforeClick={closeFunc}
/>
<Space h="md" />
<Title order={5}>{t`About`}</Title>
<DocumentationLinks links={aboutLinks} />
<MenuLinks
title={t`About`}
links={menuItemsAbout}
beforeClick={closeFunc}
/>
</div>
</Flex>
);

View File

@ -28,6 +28,7 @@ import { UserStateProps, useUserState } from '../../states/UserState';
* @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')
* @param context - Any additional context data which may be passed to the plugin
*/
export type InvenTreeContext = {
api: AxiosInstance;
@ -38,6 +39,7 @@ export type InvenTreeContext = {
navigate: NavigateFunction;
theme: MantineTheme;
colorScheme: MantineColorScheme;
context?: any;
};
export const useInvenTreeContext = () => {

View File

@ -147,10 +147,7 @@ export default function PluginDrawer({
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<PluginSettingsPanel
pluginInstance={pluginInstance}
pluginAdmin={pluginAdmin}
/>
<PluginSettingsPanel pluginAdmin={pluginAdmin} />
</Card>
</Accordion.Panel>
</Accordion.Item>

View File

@ -1,53 +1,9 @@
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 { Stack } from '@mantine/core';
import { ReactNode } from 'react';
import { InvenTreeContext } 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;
context?: any;
source?: string;
};
export async function isPluginPanelHidden({
pluginProps,
pluginContext
}: {
pluginProps: PluginPanelProps;
pluginContext: InvenTreeContext;
}): 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;
}
}
import { PluginUIFeature } from './PluginUIFeature';
import RemoteComponent from './RemoteComponent';
/**
* A custom panel which can be used to display plugin content.
@ -63,63 +19,19 @@ export async function isPluginPanelHidden({
* - `params` is the set of run-time parameters to pass to the content rendering function
*/
export default function PluginPanelContent({
pluginProps,
pluginFeature,
pluginContext
}: Readonly<{
pluginProps: PluginPanelProps;
pluginFeature: PluginUIFeature;
pluginContext: InvenTreeContext;
}>): 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` + `: ${error}`
);
}
} 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>
<RemoteComponent
source={pluginFeature.source}
defaultFunctionName="renderPanel"
context={pluginContext}
/>
</Stack>
);
}

View File

@ -1,10 +1,5 @@
import { t } from '@lingui/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useInvenTreeContext } from './PluginContext';
import { findExternalPluginFunction } from './PluginSource';
import RemoteComponent from './RemoteComponent';
/**
* Interface for the plugin admin data
@ -22,65 +17,17 @@ export interface PluginAdminInterface {
* which exports a function `renderPluginSettings`
*/
export default function PluginSettingsPanel({
pluginInstance,
pluginAdmin
}: {
pluginInstance: any;
pluginAdmin: PluginAdminInterface;
}) {
const ref = useRef<HTMLDivElement>();
const [error, setError] = useState<string | undefined>(undefined);
const pluginContext = useInvenTreeContext();
const pluginSourceFile = useMemo(() => pluginAdmin?.source, [pluginInstance]);
const loadPluginSettingsContent = async () => {
if (pluginSourceFile) {
findExternalPluginFunction(pluginSourceFile, 'renderPluginSettings').then(
(func) => {
if (func) {
try {
func(ref.current, {
...pluginContext,
context: pluginAdmin.context
});
setError('');
} catch (error) {
setError(
t`Error occurred while rendering plugin settings` + `: ${error}`
);
}
} else {
setError(t`Plugin did not provide settings rendering function`);
}
}
);
}
};
useEffect(() => {
loadPluginSettingsContent();
}, [pluginSourceFile]);
if (!pluginSourceFile) {
return null;
}
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>
</>
<RemoteComponent
source={pluginAdmin.source}
defaultFunctionName="renderPluginSettings"
context={{ ...pluginContext, context: pluginAdmin.context }}
/>
);
}

View File

@ -21,6 +21,42 @@ import {
TemplatePreviewUIFeature
} from './PluginUIFeatureTypes';
/**
* Enumeration for available plugin UI feature types.
*/
export enum PluginUIFeatureType {
dashboard = 'dashboard',
panel = 'panel',
template_editor = 'template_editor',
template_preview = 'template_preview'
}
/**
* Type definition for a UI component which can be loaded via plugin.
* Ref: src/backend/InvenTree/plugin/base/ui/serializers.py:PluginUIFeatureSerializer
*
* @param plugin_name: The name of the plugin
* @param feature_type: The type of the UI feature (see PluginUIFeatureType)
* @param key: The unique key for the feature (used to identify the feature in the DOM)
* @param title: The title of the feature (human readable)
* @param description: A description of the feature (human readable, optional)
* @param options: Additional options for the feature (optional, depends on the feature type)
* @param context: Additional context data passed to the rendering function (optional)
* @param source: The source of the feature (must point to an accessible javascript module)
*
*/
export interface PluginUIFeature {
plugin_name: string;
feature_type: PluginUIFeatureType;
key: string;
title: string;
description?: string;
icon?: string;
options?: any;
context?: any;
source: string;
}
export const getPluginTemplateEditor = (
func: PluginUIFuncWithoutInvenTreeContextType<TemplateEditorUIFeature>,
template: TemplateI

View File

@ -3,6 +3,7 @@ import { InvenTreeIconType } from '../../functions/icons';
import { TemplateI } from '../../tables/settings/TemplateTable';
import { TemplateEditorProps } from '../editors/TemplateEditor/TemplateEditor';
import { InvenTreeContext } from './PluginContext';
import { PluginUIFeature } from './PluginUIFeature';
// #region Type Helpers
export type BaseUIFeature = {
@ -35,11 +36,7 @@ export type TemplateEditorUIFeature = {
template_type: ModelType.labeltemplate | ModelType.reporttemplate;
template_model: ModelType;
};
responseOptions: {
key: string;
title: string;
icon: InvenTreeIconType;
};
responseOptions: PluginUIFeature;
featureContext: {
ref: HTMLDivElement;
registerHandlers: (handlers: {

View File

@ -0,0 +1,105 @@
import { t } from '@lingui/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { identifierString } from '../../functions/conversion';
import { Boundary } from '../Boundary';
import { InvenTreeContext } from './PluginContext';
import { findExternalPluginFunction } from './PluginSource';
/**
* A remote component which can be used to display plugin content.
* Content is loaded dynamically (from an external source).
*
* @param pluginFeature: The plugin feature to render
* @param defaultFunctionName: The default function name to call (if not overridden by pluginFeature.source)
* @param pluginContext: The context to pass to the plugin function
*
*/
export default function RemoteComponent({
source,
defaultFunctionName,
context
}: {
source: string;
defaultFunctionName: string;
context: InvenTreeContext;
}) {
const componentRef = useRef<HTMLDivElement>();
const [renderingError, setRenderingError] = useState<string | undefined>(
undefined
);
const sourceFile = useMemo(() => {
return source.split(':')[0];
}, [source]);
// Determine the function to call in the external plugin source
const functionName = useMemo(() => {
// The "source" string may contain a function name, e.g. "source.js:myFunction"
if (source.includes(':')) {
return source.split(':')[1];
}
// By default, return the default function name
return defaultFunctionName;
}, [source, defaultFunctionName]);
const reloadPluginContent = async () => {
if (!componentRef.current) {
return;
}
if (sourceFile && functionName) {
findExternalPluginFunction(sourceFile, functionName).then((func) => {
if (func) {
try {
func(componentRef.current, context);
setRenderingError('');
} catch (error) {
setRenderingError(`${error}`);
}
} else {
setRenderingError(`${sourceFile}:${functionName}`);
}
});
} else {
setRenderingError(
t`Invalid source or function name` + ` - ${sourceFile}:${functionName}`
);
}
};
// Reload the plugin content dynamically
useEffect(() => {
reloadPluginContent();
}, [sourceFile, functionName, context]);
return (
<>
<Boundary
label={identifierString(
`RemoteComponent-${sourceFile}-${functionName}`
)}
>
<Stack gap="xs">
{renderingError && (
<Alert
color="red"
title={t`Error Loading Content`}
icon={<IconExclamationCircle />}
>
<Text>
{t`Error occurred while loading plugin content`}:{' '}
{renderingError}
</Text>
</Alert>
)}
<div ref={componentRef as any}></div>
</Stack>
</Boundary>
</>
);
}

View File

@ -106,7 +106,6 @@ export type RenderInstanceProps = {
*/
export function RenderInstance(props: RenderInstanceProps): ReactNode {
if (props.model === undefined) {
console.error('RenderInstance: No model provided');
return <UnknownRenderer model={props.model} />;
}
@ -115,7 +114,6 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
const RenderComponent = RendererLookup[model_name];
if (!RenderComponent) {
console.error(`RenderInstance: No renderer for model ${props.model}`);
return <UnknownRenderer model={props.model} />;
}

View File

@ -2,6 +2,7 @@ import { t } from '@lingui/macro';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { InvenTreeIconType } from '../../functions/icons';
export interface ModelInformationInterface {
label: string;
@ -11,6 +12,7 @@ export interface ModelInformationInterface {
api_endpoint: ApiEndpoints;
cui_detail?: string;
admin_url?: string;
icon: InvenTreeIconType;
}
export interface TranslatableModelInformationInterface
@ -27,25 +29,28 @@ export const ModelInformationDict: ModelDict = {
part: {
label: () => t`Part`,
label_multiple: () => t`Parts`,
url_overview: '/part',
url_overview: '/part/category/index/parts',
url_detail: '/part/:pk/',
cui_detail: '/part/:pk/',
api_endpoint: ApiEndpoints.part_list,
admin_url: '/part/part/'
admin_url: '/part/part/',
icon: 'part'
},
partparametertemplate: {
label: () => t`Part Parameter Template`,
label_multiple: () => t`Part Parameter Templates`,
url_overview: '/partparametertemplate',
url_detail: '/partparametertemplate/:pk/',
api_endpoint: ApiEndpoints.part_parameter_template_list
api_endpoint: ApiEndpoints.part_parameter_template_list,
icon: 'test_templates'
},
parttesttemplate: {
label: () => t`Part Test Template`,
label_multiple: () => t`Part Test Templates`,
url_overview: '/parttesttemplate',
url_detail: '/parttesttemplate/:pk/',
api_endpoint: ApiEndpoints.part_test_template_list
api_endpoint: ApiEndpoints.part_test_template_list,
icon: 'test'
},
supplierpart: {
label: () => t`Supplier Part`,
@ -54,7 +59,8 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/supplier-part/:pk/',
cui_detail: '/supplier-part/:pk/',
api_endpoint: ApiEndpoints.supplier_part_list,
admin_url: '/company/supplierpart/'
admin_url: '/company/supplierpart/',
icon: 'supplier_part'
},
manufacturerpart: {
label: () => t`Manufacturer Part`,
@ -63,25 +69,28 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/purchasing/manufacturer-part/:pk/',
cui_detail: '/manufacturer-part/:pk/',
api_endpoint: ApiEndpoints.manufacturer_part_list,
admin_url: '/company/manufacturerpart/'
admin_url: '/company/manufacturerpart/',
icon: 'manufacturers'
},
partcategory: {
label: () => t`Part Category`,
label_multiple: () => t`Part Categories`,
url_overview: '/part/category',
url_overview: '/part/category/parts/subcategories',
url_detail: '/part/category/:pk/',
cui_detail: '/part/category/:pk/',
api_endpoint: ApiEndpoints.category_list,
admin_url: '/part/partcategory/'
admin_url: '/part/partcategory/',
icon: 'category'
},
stockitem: {
label: () => t`Stock Item`,
label_multiple: () => t`Stock Items`,
url_overview: '/stock/item',
url_overview: '/stock/location/index/stock-items',
url_detail: '/stock/item/:pk/',
cui_detail: '/stock/item/:pk/',
api_endpoint: ApiEndpoints.stock_item_list,
admin_url: '/stock/stockitem/'
admin_url: '/stock/stockitem/',
icon: 'stock'
},
stocklocation: {
label: () => t`Stock Location`,
@ -90,26 +99,30 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/stock/location/:pk/',
cui_detail: '/stock/location/:pk/',
api_endpoint: ApiEndpoints.stock_location_list,
admin_url: '/stock/stocklocation/'
admin_url: '/stock/stocklocation/',
icon: 'location'
},
stocklocationtype: {
label: () => t`Stock Location Type`,
label_multiple: () => t`Stock Location Types`,
api_endpoint: ApiEndpoints.stock_location_type_list
api_endpoint: ApiEndpoints.stock_location_type_list,
icon: 'location'
},
stockhistory: {
label: () => t`Stock History`,
label_multiple: () => t`Stock Histories`,
api_endpoint: ApiEndpoints.stock_tracking_list
api_endpoint: ApiEndpoints.stock_tracking_list,
icon: 'history'
},
build: {
label: () => t`Build`,
label_multiple: () => t`Builds`,
url_overview: '/manufacturing/build-order/',
url_overview: '/manufacturing/index/buildorders/',
url_detail: '/manufacturing/build-order/:pk/',
cui_detail: '/build/:pk/',
api_endpoint: ApiEndpoints.build_order_list,
admin_url: '/build/build/'
admin_url: '/build/build/',
icon: 'build_order'
},
buildline: {
label: () => t`Build Line`,
@ -117,12 +130,14 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/build/line',
url_detail: '/build/line/:pk/',
cui_detail: '/build/line/:pk/',
api_endpoint: ApiEndpoints.build_line_list
api_endpoint: ApiEndpoints.build_line_list,
icon: 'build_order'
},
builditem: {
label: () => t`Build Item`,
label_multiple: () => t`Build Items`,
api_endpoint: ApiEndpoints.build_item_list
api_endpoint: ApiEndpoints.build_item_list,
icon: 'build_order'
},
company: {
label: () => t`Company`,
@ -131,86 +146,98 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/company/:pk/',
cui_detail: '/company/:pk/',
api_endpoint: ApiEndpoints.company_list,
admin_url: '/company/company/'
admin_url: '/company/company/',
icon: 'building'
},
projectcode: {
label: () => t`Project Code`,
label_multiple: () => t`Project Codes`,
url_overview: '/project-code',
url_detail: '/project-code/:pk/',
api_endpoint: ApiEndpoints.project_code_list
api_endpoint: ApiEndpoints.project_code_list,
icon: 'list_details'
},
purchaseorder: {
label: () => t`Purchase Order`,
label_multiple: () => t`Purchase Orders`,
url_overview: '/purchasing/purchase-order',
url_overview: '/purchasing/index/purchaseorders',
url_detail: '/purchasing/purchase-order/:pk/',
cui_detail: '/order/purchase-order/:pk/',
api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/'
admin_url: '/order/purchaseorder/',
icon: 'purchase_orders'
},
purchaseorderlineitem: {
label: () => t`Purchase Order Line`,
label_multiple: () => t`Purchase Order Lines`,
api_endpoint: ApiEndpoints.purchase_order_line_list
api_endpoint: ApiEndpoints.purchase_order_line_list,
icon: 'purchase_orders'
},
salesorder: {
label: () => t`Sales Order`,
label_multiple: () => t`Sales Orders`,
url_overview: '/sales/sales-order',
url_overview: '/sales/index/salesorders',
url_detail: '/sales/sales-order/:pk/',
cui_detail: '/order/sales-order/:pk/',
api_endpoint: ApiEndpoints.sales_order_list,
admin_url: '/order/salesorder/'
admin_url: '/order/salesorder/',
icon: 'sales_orders'
},
salesordershipment: {
label: () => t`Sales Order Shipment`,
label_multiple: () => t`Sales Order Shipments`,
url_overview: '/sales/shipment/',
url_detail: '/sales/shipment/:pk/',
api_endpoint: ApiEndpoints.sales_order_shipment_list
api_endpoint: ApiEndpoints.sales_order_shipment_list,
icon: 'sales_orders'
},
returnorder: {
label: () => t`Return Order`,
label_multiple: () => t`Return Orders`,
url_overview: '/sales/return-order',
url_overview: '/sales/index/returnorders',
url_detail: '/sales/return-order/:pk/',
cui_detail: '/order/return-order/:pk/',
api_endpoint: ApiEndpoints.return_order_list,
admin_url: '/order/returnorder/'
admin_url: '/order/returnorder/',
icon: 'return_orders'
},
returnorderlineitem: {
label: () => t`Return Order Line Item`,
label_multiple: () => t`Return Order Line Items`,
api_endpoint: ApiEndpoints.return_order_line_list
api_endpoint: ApiEndpoints.return_order_line_list,
icon: 'return_orders'
},
address: {
label: () => t`Address`,
label_multiple: () => t`Addresses`,
url_overview: '/address',
url_detail: '/address/:pk/',
api_endpoint: ApiEndpoints.address_list
api_endpoint: ApiEndpoints.address_list,
icon: 'address'
},
contact: {
label: () => t`Contact`,
label_multiple: () => t`Contacts`,
url_overview: '/contact',
url_detail: '/contact/:pk/',
api_endpoint: ApiEndpoints.contact_list
api_endpoint: ApiEndpoints.contact_list,
icon: 'group'
},
owner: {
label: () => t`Owner`,
label_multiple: () => t`Owners`,
url_overview: '/owner',
url_detail: '/owner/:pk/',
api_endpoint: ApiEndpoints.owner_list
api_endpoint: ApiEndpoints.owner_list,
icon: 'group'
},
user: {
label: () => t`User`,
label_multiple: () => t`Users`,
url_overview: '/user',
url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list
api_endpoint: ApiEndpoints.user_list,
icon: 'user'
},
group: {
label: () => t`Group`,
@ -218,47 +245,54 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/user/group',
url_detail: '/user/group-:pk',
api_endpoint: ApiEndpoints.group_list,
admin_url: '/auth/group/'
admin_url: '/auth/group/',
icon: 'group'
},
importsession: {
label: () => t`Import Session`,
label_multiple: () => t`Import Sessions`,
url_overview: '/import',
url_detail: '/import/:pk/',
api_endpoint: ApiEndpoints.import_session_list
api_endpoint: ApiEndpoints.import_session_list,
icon: 'import'
},
labeltemplate: {
label: () => t`Label Template`,
label_multiple: () => t`Label Templates`,
url_overview: '/labeltemplate',
url_detail: '/labeltemplate/:pk/',
api_endpoint: ApiEndpoints.label_list
api_endpoint: ApiEndpoints.label_list,
icon: 'labels'
},
reporttemplate: {
label: () => t`Report Template`,
label_multiple: () => t`Report Templates`,
url_overview: '/reporttemplate',
url_detail: '/reporttemplate/:pk/',
api_endpoint: ApiEndpoints.report_list
api_endpoint: ApiEndpoints.report_list,
icon: 'reports'
},
pluginconfig: {
label: () => t`Plugin Configuration`,
label_multiple: () => t`Plugin Configurations`,
url_overview: '/pluginconfig',
url_detail: '/pluginconfig/:pk/',
api_endpoint: ApiEndpoints.plugin_list
api_endpoint: ApiEndpoints.plugin_list,
icon: 'plugin'
},
contenttype: {
label: () => t`Content Type`,
label_multiple: () => t`Content Types`,
api_endpoint: ApiEndpoints.content_type_list
api_endpoint: ApiEndpoints.content_type_list,
icon: 'list_details'
},
error: {
label: () => t`Error`,
label_multiple: () => t`Errors`,
api_endpoint: ApiEndpoints.error_report_list,
url_overview: '/settings/admin/errors',
url_detail: '/settings/admin/errors/:pk/'
url_detail: '/settings/admin/errors/:pk/',
icon: 'exclamation'
}
};

View File

@ -1,29 +0,0 @@
import { Trans } from '@lingui/macro';
import { SimpleGrid, Title } from '@mantine/core';
import { ColorToggle } from '../items/ColorToggle';
import { LanguageSelect } from '../items/LanguageSelect';
export default function DisplayWidget() {
return (
<span>
<Title order={5}>
<Trans>Display Settings</Trans>
</Title>
<SimpleGrid cols={2} spacing={0}>
<div>
<Trans>Color Mode</Trans>
</div>
<div>
<ColorToggle />
</div>
<div>
<Trans>Language</Trans>
</div>
<div>
<LanguageSelect width={140} />
</div>
</SimpleGrid>
</span>
);
}

View File

@ -1,36 +0,0 @@
import { Trans } from '@lingui/macro';
import { Button, Stack, Title, useMantineColorScheme } from '@mantine/core';
import { IconExternalLink } from '@tabler/icons-react';
import { vars } from '../../theme';
export default function FeedbackWidget() {
const { colorScheme } = useMantineColorScheme();
return (
<Stack
style={{
backgroundColor:
colorScheme === 'dark' ? vars.colors.gray[9] : vars.colors.gray[1],
borderRadius: vars.radius.md
}}
p={15}
>
<Title order={5}>
<Trans>Something is new: Platform UI</Trans>
</Title>
<Trans>
We are building a new UI with a modern stack. What you currently see is
not fixed and will be redesigned but demonstrates the UI/UX
possibilities we will have going forward.
</Trans>
<Button
component="a"
href="https://github.com/inventree/InvenTree/discussions/5328"
variant="outline"
leftSection={<IconExternalLink size="0.9rem" />}
>
<Trans>Provide Feedback</Trans>
</Button>
</Stack>
);
}

View File

@ -1,16 +0,0 @@
import { Trans } from '@lingui/macro';
import { Title } from '@mantine/core';
import { navDocLinks } from '../../defaults/links';
import { GettingStartedCarousel } from '../items/GettingStartedCarousel';
export default function GetStartedWidget() {
return (
<span>
<Title order={5}>
<Trans>Getting Started</Trans>
</Title>
<GettingStartedCarousel items={navDocLinks} />
</span>
);
}

View File

@ -1,16 +0,0 @@
import { style } from '@vanilla-extract/css';
import { vars } from '../../theme';
export const backgroundItem = style({
maxWidth: '100%',
padding: '8px',
boxShadow: vars.shadows.md,
[vars.lightSelector]: { backgroundColor: vars.colors.white },
[vars.darkSelector]: { backgroundColor: vars.colors.dark[5] }
});
export const baseItem = style({
maxWidth: '100%',
padding: '8px'
});

View File

@ -1,232 +0,0 @@
import { Trans } from '@lingui/macro';
import {
ActionIcon,
Container,
Group,
Indicator,
Menu,
Text
} from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import {
IconArrowBackUpDouble,
IconDotsVertical,
IconLayout2,
IconSquare,
IconSquareCheck
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import * as classes from './WidgetLayout.css';
const ReactGridLayout = WidthProvider(Responsive);
interface LayoutStorage {
[key: string]: {};
}
const compactType = 'vertical';
export interface LayoutItemType {
i: number;
val: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
w?: number;
h?: number;
x?: number;
y?: number;
minH?: number;
}
export function WidgetLayout({
items = [],
className = 'layout',
localstorageName = 'argl',
rowHeight = 30
}: Readonly<{
items: LayoutItemType[];
className?: string;
localstorageName?: string;
rowHeight?: number;
}>) {
const [layouts, setLayouts] = useState({});
const [editable, setEditable] = useDisclosure(false);
const [boxShown, setBoxShown] = useDisclosure(true);
useEffect(() => {
let layout = getFromLS('layouts') || [];
const new_layout = JSON.parse(JSON.stringify(layout));
setLayouts(new_layout);
}, []);
function getFromLS(key: string) {
let ls: LayoutStorage = {};
if (localStorage) {
try {
ls = JSON.parse(localStorage.getItem(localstorageName) || '') || {};
} catch (e) {
/*Ignore*/
}
}
return ls[key];
}
function saveToLS(key: string, value: any) {
if (localStorage) {
localStorage.setItem(
localstorageName,
JSON.stringify({
[key]: value
})
);
}
}
function resetLayout() {
setLayouts({});
}
function onLayoutChange(layout: any, layouts: any) {
saveToLS('layouts', layouts);
setLayouts(layouts);
}
return (
<div>
<WidgetControlBar
editable={editable}
editFnc={setEditable.toggle}
resetLayout={resetLayout}
boxShown={boxShown}
boxFnc={setBoxShown.toggle}
/>
{layouts ? (
<ReactGridLayout
className={className}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={rowHeight}
layouts={layouts}
onLayoutChange={(layout, layouts) => onLayoutChange(layout, layouts)}
compactType={compactType}
isDraggable={editable}
isResizable={editable}
>
{items.map((item) => {
return LayoutItem(item, boxShown, classes);
})}
</ReactGridLayout>
) : (
<div>
<Trans>Loading</Trans>
</div>
)}
</div>
);
}
function WidgetControlBar({
editable,
editFnc,
resetLayout,
boxShown,
boxFnc
}: Readonly<{
editable: boolean;
editFnc: () => void;
resetLayout: () => void;
boxShown: boolean;
boxFnc: () => void;
}>) {
useHotkeys([['mod+E', () => editFnc()]]);
return (
<Group justify="right">
<Menu
shadow="md"
width={200}
openDelay={100}
closeDelay={400}
position="bottom-end"
>
<Menu.Target>
<Indicator
color="red"
position="bottom-start"
processing
disabled={!editable}
>
<ActionIcon variant="transparent">
<IconDotsVertical />
</ActionIcon>
</Indicator>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
<Trans>Layout</Trans>
</Menu.Label>
<Menu.Item
leftSection={<IconArrowBackUpDouble size={14} />}
onClick={resetLayout}
>
<Trans>Reset Layout</Trans>
</Menu.Item>
<Menu.Item
leftSection={
<IconLayout2 size={14} color={editable ? 'red' : undefined} />
}
onClick={editFnc}
rightSection={
<Text size="xs" c="dimmed">
E
</Text>
}
>
{editable ? <Trans>Stop Edit</Trans> : <Trans>Edit Layout</Trans>}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
<Trans>Appearance</Trans>
</Menu.Label>
<Menu.Item
leftSection={
boxShown ? (
<IconSquareCheck size={14} />
) : (
<IconSquare size={14} />
)
}
onClick={boxFnc}
>
<Trans>Show Boxes</Trans>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
}
function LayoutItem(
item: any,
backgroundColor: boolean,
classes: { backgroundItem: string; baseItem: string }
) {
return (
<Container
key={item.i}
data-grid={{
w: item.w || 3,
h: item.h || 3,
x: item.x || 0,
y: item.y || 0,
minH: item.minH || undefined,
minW: item.minW || undefined
}}
className={backgroundColor ? classes.backgroundItem : classes.baseItem}
>
{item.val}
</Container>
);
}

View File

@ -6,25 +6,17 @@ import { NavigateFunction } from 'react-router-dom';
import { useLocalState } from '../states/LocalState';
import { useUserState } from '../states/UserState';
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
import { menuItems } from './menuItems';
export function getActions(navigate: NavigateFunction) {
const setNavigationOpen = useLocalState((state) => state.setNavigationOpen);
const { user } = useUserState();
const actions: SpotlightActionData[] = [
{
id: 'home',
label: t`Home`,
description: `Go to the home page`,
onClick: () => navigate(menuItems.home.link),
leftSection: <IconHome size="1.2rem" />
},
{
id: 'dashboard',
label: t`Dashboard`,
description: t`Go to the InvenTree dashboard`,
onClick: () => navigate(menuItems.dashboard.link),
onClick: () => {}, // navigate(menuItems.dashboard.link),
leftSection: <IconLink size="1.2rem" />
},
{
@ -70,7 +62,7 @@ export function getActions(navigate: NavigateFunction) {
id: 'admin-center',
label: t`Admin Center`,
description: t`Go to the Admin Center`,
onClick: () => navigate(menuItems['settings-admin'].link),
onClick: () => {}, /// navigate(menuItems['settings-admin'].link),}
leftSection: <IconLink size="1.2rem" />
});

View File

@ -1,132 +0,0 @@
import { t } from '@lingui/macro';
import { ApiEndpoints } from '../enums/ApiEndpoints';
interface DashboardItems {
id: string;
text: string;
icon: string;
url: ApiEndpoints;
params: any;
}
export const dashboardItems: DashboardItems[] = [
{
id: 'starred-parts',
text: t`Subscribed Parts`,
icon: 'fa-bell',
url: ApiEndpoints.part_list,
params: { starred: true }
},
{
id: 'starred-categories',
text: t`Subscribed Categories`,
icon: 'fa-bell',
url: ApiEndpoints.category_list,
params: { starred: true }
},
{
id: 'latest-parts',
text: t`Latest Parts`,
icon: 'fa-newspaper',
url: ApiEndpoints.part_list,
params: { ordering: '-creation_date', limit: 10 }
},
{
id: 'bom-validation',
text: t`BOM Waiting Validation`,
icon: 'fa-times-circle',
url: ApiEndpoints.part_list,
params: { bom_valid: false }
},
{
id: 'recently-updated-stock',
text: t`Recently Updated`,
icon: 'fa-clock',
url: ApiEndpoints.stock_item_list,
params: { part_detail: true, ordering: '-updated', limit: 10 }
},
{
id: 'low-stock',
text: t`Low Stock`,
icon: 'fa-flag',
url: ApiEndpoints.part_list,
params: { low_stock: true }
},
{
id: 'depleted-stock',
text: t`Depleted Stock`,
icon: 'fa-times',
url: ApiEndpoints.part_list,
params: { depleted_stock: true }
},
{
id: 'stock-to-build',
text: t`Required for Build Orders`,
icon: 'fa-bullhorn',
url: ApiEndpoints.part_list,
params: { stock_to_build: true }
},
{
id: 'expired-stock',
text: t`Expired Stock`,
icon: 'fa-calendar-times',
url: ApiEndpoints.stock_item_list,
params: { expired: true }
},
{
id: 'stale-stock',
text: t`Stale Stock`,
icon: 'fa-stopwatch',
url: ApiEndpoints.stock_item_list,
params: { stale: true, expired: true }
},
{
id: 'build-pending',
text: t`Build Orders In Progress`,
icon: 'fa-cogs',
url: ApiEndpoints.build_order_list,
params: { active: true }
},
{
id: 'build-overdue',
text: t`Overdue Build Orders`,
icon: 'fa-calendar-times',
url: ApiEndpoints.build_order_list,
params: { overdue: true }
},
{
id: 'po-outstanding',
text: t`Outstanding Purchase Orders`,
icon: 'fa-sign-in-alt',
url: ApiEndpoints.purchase_order_list,
params: { supplier_detail: true, outstanding: true }
},
{
id: 'po-overdue',
text: t`Overdue Purchase Orders`,
icon: 'fa-calendar-times',
url: ApiEndpoints.purchase_order_list,
params: { supplier_detail: true, overdue: true }
},
{
id: 'so-outstanding',
text: t`Outstanding Sales Orders`,
icon: 'fa-sign-out-alt',
url: ApiEndpoints.sales_order_list,
params: { customer_detail: true, outstanding: true }
},
{
id: 'so-overdue',
text: t`Overdue Sales Orders`,
icon: 'fa-calendar-times',
url: ApiEndpoints.sales_order_list,
params: { customer_detail: true, overdue: true }
},
{
id: 'news',
text: t`Current News`,
icon: 'fa-newspaper',
url: ApiEndpoints.news,
params: {}
}
];

View File

@ -1,31 +1,12 @@
import { Trans } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import { openContextModal } from '@mantine/modals';
import { DocumentationLinkItem } from '../components/items/DocumentationLinks';
import { MenuLinkItem } from '../components/items/MenuLinks';
import { StylishText } from '../components/items/StylishText';
import { UserRoles } from '../enums/Roles';
import { IS_DEV_OR_DEMO } from '../main';
export const footerLinks = [
{
link: 'https://inventree.org/',
label: <Trans>Website</Trans>,
key: 'website'
},
{
link: 'https://github.com/inventree/InvenTree',
label: <Trans>GitHub</Trans>,
key: 'github'
},
{
link: 'https://demo.inventree.org/',
label: <Trans>Demo</Trans>,
key: 'demo'
}
];
export const navTabs = [
{ text: <Trans>Home</Trans>, name: 'home' },
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
{ text: <Trans>Dashboard</Trans>, name: 'home' },
{ text: <Trans>Parts</Trans>, name: 'part', role: UserRoles.part },
{ text: <Trans>Stock</Trans>, name: 'stock', role: UserRoles.stock },
{
@ -43,39 +24,52 @@ export const navTabs = [
export const docLinks = {
app: 'https://docs.inventree.org/app/',
getting_started: 'https://docs.inventree.org/en/latest/getting_started/',
getting_started: 'https://docs.inventree.org/en/latest/start/intro/',
api: 'https://docs.inventree.org/en/latest/api/api/',
developer: 'https://docs.inventree.org/en/latest/develop/starting/',
faq: 'https://docs.inventree.org/en/latest/faq/'
developer: 'https://docs.inventree.org/en/latest/develop/contributing/',
faq: 'https://docs.inventree.org/en/latest/faq/',
github: 'https://github.com/inventree/inventree'
};
export const navDocLinks: DocumentationLinkItem[] = [
{
id: 'getting_started',
title: <Trans>Getting Started</Trans>,
description: <Trans>Getting started with InvenTree</Trans>,
link: docLinks.getting_started,
placeholder: true
},
{
id: 'api',
title: <Trans>API</Trans>,
description: <Trans>InvenTree API documentation</Trans>,
link: docLinks.api
},
{
id: 'developer',
title: <Trans>Developer Manual</Trans>,
description: <Trans>InvenTree developer manual</Trans>,
link: docLinks.developer
},
{
id: 'faq',
title: <Trans>FAQ</Trans>,
description: <Trans>Frequently asked questions</Trans>,
link: docLinks.faq
}
];
export function DocumentationLinks(): MenuLinkItem[] {
return [
{
id: 'gettin-started',
title: t`Getting Started`,
link: docLinks.getting_started,
external: true,
description: t`Getting started with InvenTree`
},
{
id: 'api',
title: t`API`,
link: docLinks.api,
external: true,
description: t`InvenTree API documentation`
},
{
id: 'developer',
title: t`Developer Manual`,
link: docLinks.developer,
external: true,
description: t`InvenTree developer manual`
},
{
id: 'faq',
title: t`FAQ`,
link: docLinks.faq,
external: true,
description: t`Frequently asked questions`
},
{
id: 'github',
title: t`GitHub Repository`,
link: docLinks.github,
external: true,
description: t`InvenTree source code on GitHub`
}
];
}
export function serverInfo() {
return openContextModal({
@ -116,23 +110,28 @@ export function licenseInfo() {
});
}
export const aboutLinks: DocumentationLinkItem[] = [
{
id: 'instance',
title: <Trans>System Information</Trans>,
description: <Trans>About this Inventree instance</Trans>,
action: serverInfo
},
{
id: 'about',
title: <Trans>About InvenTree</Trans>,
description: <Trans>About the InvenTree org</Trans>,
action: aboutInvenTree
},
{
id: 'licenses',
title: <Trans>Licenses</Trans>,
description: <Trans>Licenses for dependencies of the service</Trans>,
action: licenseInfo
}
];
export function AboutLinks(): MenuLinkItem[] {
return [
{
id: 'instance',
title: t`System Information`,
description: t`About this Inventree instance`,
icon: 'info',
action: serverInfo
},
{
id: 'about',
title: t`About InvenTree`,
description: t`About the InvenTree Project`,
icon: 'info',
action: aboutInvenTree
},
{
id: 'licenses',
title: t`License Information`,
description: t`Licenses for dependencies of the InvenTree software`,
icon: 'license',
action: licenseInfo
}
];
}

View File

@ -1,66 +0,0 @@
import { Trans } from '@lingui/macro';
import { menuItemsCollection } from '../components/items/MenuLinks';
import { IS_DEV_OR_DEMO } from '../main';
export const menuItems: menuItemsCollection = {
home: {
id: 'home',
text: <Trans>Home</Trans>,
link: '/',
highlight: true
},
profile: {
id: 'profile',
text: <Trans>Account Settings</Trans>,
link: '/settings/user',
doctext: <Trans>User attributes and design settings.</Trans>
},
scan: {
id: 'scan',
text: <Trans>Scanning</Trans>,
link: '/scan',
doctext: <Trans>View for interactive scanning and multiple actions.</Trans>,
highlight: true
},
dashboard: {
id: 'dashboard',
text: <Trans>Dashboard</Trans>,
link: '/dashboard'
},
parts: {
id: 'parts',
text: <Trans>Parts</Trans>,
link: '/part/'
},
stock: {
id: 'stock',
text: <Trans>Stock</Trans>,
link: '/stock'
},
build: {
id: 'manufacturing',
text: <Trans>Manufacturing</Trans>,
link: '/manufacturing/'
},
purchasing: {
id: 'purchasing',
text: <Trans>Purchasing</Trans>,
link: '/purchasing/'
},
sales: {
id: 'sales',
text: <Trans>Sales</Trans>,
link: '/sales/'
},
'settings-system': {
id: 'settings-system',
text: <Trans>System Settings</Trans>,
link: '/settings/system'
},
'settings-admin': {
id: 'settings-admin',
text: <Trans>Admin Center</Trans>,
link: '/settings/admin'
}
};

View File

@ -201,7 +201,6 @@ export enum ApiEndpoints {
plugin_admin = 'plugins/:key/admin/',
// User interface plugin endpoints
plugin_panel_list = 'plugins/ui/panels/',
plugin_ui_features_list = 'plugins/ui/features/:feature_type/',
// Machine API endpoints

View File

@ -35,6 +35,7 @@ import {
IconEdit,
IconExclamationCircle,
IconExternalLink,
IconFileArrowLeft,
IconFileDownload,
IconFileUpload,
IconFlag,
@ -44,13 +45,18 @@ import {
IconHandStop,
IconHash,
IconHierarchy,
IconHistory,
IconInfoCircle,
IconLayersLinked,
IconLayoutDashboard,
IconLicense,
IconLink,
IconList,
IconListDetails,
IconListTree,
IconLock,
IconMail,
IconMap2,
IconMapPin,
IconMapPinHeart,
IconMinusVertical,
@ -71,6 +77,7 @@ import {
IconQuestionMark,
IconRefresh,
IconRulerMeasure,
IconSettings,
IconShoppingCart,
IconShoppingCartHeart,
IconShoppingCartPlus,
@ -90,6 +97,7 @@ import {
IconTruckReturn,
IconUnlink,
IconUser,
IconUserBolt,
IconUserStar,
IconUsersGroup,
IconVersions,
@ -124,6 +132,7 @@ const icons = {
details: IconInfoCircle,
parameters: IconList,
list: IconList,
list_details: IconListDetails,
stock: IconPackages,
variants: IconVersions,
allocations: IconBookmarks,
@ -163,8 +172,13 @@ const icons = {
issue: IconBrandTelegram,
complete: IconCircleCheck,
deliver: IconTruckDelivery,
address: IconMap2,
import: IconFileArrowLeft,
bell: IconBell,
notification: IconBell,
admin: IconUserBolt,
system: IconSettings,
license: IconLicense,
// Part Icons
active: IconCheck,
@ -210,6 +224,7 @@ const icons = {
arrow_down: IconArrowBigDownLineFilled,
transfer: IconTransfer,
actions: IconDots,
labels: IconTag,
reports: IconPrinter,
buy: IconShoppingCartPlus,
add: IconCirclePlus,
@ -236,7 +251,9 @@ const icons = {
repeat_destination: IconFlagShare,
unlink: IconUnlink,
success: IconCircleCheck,
plugin: IconPlug
plugin: IconPlug,
history: IconHistory,
dashboard: IconLayoutDashboard
};
export type InvenTreeIconType = keyof typeof icons;
@ -248,8 +265,8 @@ export type TablerIconType = React.ForwardRefExoticComponent<
* Returns a Tabler Icon for the model field name supplied
* @param field string defining field name
*/
export function GetIcon(field: InvenTreeIconType) {
return icons[field];
export function GetIcon(field: string): TablerIconType {
return icons[field as InvenTreeIconType];
}
// Aliasing the new type name to make it distinct

View File

@ -0,0 +1,116 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from '../App';
import { DashboardWidgetProps } from '../components/dashboard/DashboardWidget';
import DashboardWidgetLibrary from '../components/dashboard/DashboardWidgetLibrary';
import { useInvenTreeContext } from '../components/plugins/PluginContext';
import {
PluginUIFeature,
PluginUIFeatureType
} from '../components/plugins/PluginUIFeature';
import RemoteComponent from '../components/plugins/RemoteComponent';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { identifierString } from '../functions/conversion';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { useUserState } from '../states/UserState';
interface DashboardLibraryProps {
items: DashboardWidgetProps[];
loaded: boolean;
}
/**
* Custom hook to load available dashboard items.
*
* - Loads from library of "builtin" dashboard items
* - Loads plugin-defined dashboard items (via the API)
*/
export function useDashboardItems(): DashboardLibraryProps {
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const pluginsEnabled: boolean = useMemo(
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
[globalSettings]
);
const builtin = DashboardWidgetLibrary();
const pluginQuery = useQuery({
enabled: pluginsEnabled,
queryKey: ['plugin-dashboard-items', user],
refetchOnMount: true,
queryFn: async () => {
if (!pluginsEnabled) {
return Promise.resolve([]);
}
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
feature_type: PluginUIFeatureType.dashboard
});
return api
.get(url)
.then((response: any) => response.data)
.catch((_error: any) => {
console.error('ERR: Failed to fetch plugin dashboard items');
return [];
});
}
});
// Cache the context data which is delivered to the plugins
const inventreeContext = useInvenTreeContext();
const pluginDashboardItems: DashboardWidgetProps[] = useMemo(() => {
return (
pluginQuery?.data?.map((item: PluginUIFeature) => {
const pluginContext = {
...inventreeContext,
context: item.context
};
return {
label: identifierString(`p-${item.plugin_name}-${item.key}`),
title: item.title,
description: item.description,
minWidth: item.options?.width ?? 2,
minHeight: item.options?.height ?? 1,
render: () => {
return (
<RemoteComponent
source={item.source}
defaultFunctionName="renderDashboardItem"
context={pluginContext}
/>
);
}
};
}) ?? []
);
}, [pluginQuery, inventreeContext]);
const items: DashboardWidgetProps[] = useMemo(() => {
return [...builtin, ...pluginDashboardItems];
}, [builtin, pluginDashboardItems]);
const loaded: boolean = useMemo(() => {
if (pluginsEnabled) {
return (
!pluginQuery.isFetching &&
!pluginQuery.isLoading &&
pluginQuery.isFetched &&
pluginQuery.isSuccess
);
} else {
return true;
}
}, [pluginsEnabled, pluginQuery]);
return {
items: items,
loaded: loaded
};
}

View File

@ -1,10 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { QueryObserverResult, useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { PathParams, apiUrl } from '../states/ApiState';
export interface UseInstanceResult {
instance: any;
setInstance: (instance: any) => void;
refreshInstance: () => Promise<QueryObserverResult<any, any>>;
instanceQuery: any;
requestStatus: number;
isLoaded: boolean;
}
/**
* Custom hook for loading a single instance of an instance from the API
*
@ -36,7 +45,7 @@ export function useInstance<T = any>({
refetchOnWindowFocus?: boolean;
throwError?: boolean;
updateInterval?: number;
}) {
}): UseInstanceResult {
const [instance, setInstance] = useState<T | undefined>(defaultValue);
const [requestStatus, setRequestStatus] = useState<number>(0);
@ -95,6 +104,14 @@ export function useInstance<T = any>({
refetchInterval: updateInterval
});
const isLoaded = useMemo(() => {
return (
instanceQuery.isFetched &&
instanceQuery.isSuccess &&
!instanceQuery.isError
);
}, [instanceQuery]);
const refreshInstance = useCallback(function () {
return instanceQuery.refetch();
}, []);
@ -104,6 +121,7 @@ export function useInstance<T = any>({
setInstance,
refreshInstance,
instanceQuery,
requestStatus
requestStatus,
isLoaded
};
}

View File

@ -0,0 +1,14 @@
import { useMemo } from 'react';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
* Simple hook for returning the "instance name" of the Server
*/
export default function useInstanceName(): string {
const globalSettings = useGlobalSettingsState();
return useMemo(() => {
return globalSettings.getSetting('INVENTREE_INSTANCE', 'InvenTree');
}, [globalSettings]);
}

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { api } from '../App';
import { PanelType } from '../components/panels/Panel';
@ -7,10 +7,11 @@ import {
InvenTreeContext,
useInvenTreeContext
} from '../components/plugins/PluginContext';
import PluginPanelContent, {
PluginPanelProps,
isPluginPanelHidden
} from '../components/plugins/PluginPanel';
import PluginPanelContent from '../components/plugins/PluginPanel';
import {
PluginUIFeature,
PluginUIFeatureType
} from '../components/plugins/PluginUIFeature';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { identifierString } from '../functions/conversion';
@ -54,16 +55,20 @@ export function usePluginPanels({
return Promise.resolve([]);
}
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
feature_type: PluginUIFeatureType.panel
});
return api
.get(apiUrl(ApiEndpoints.plugin_panel_list), {
.get(url, {
params: {
target_model: model,
target_id: id
}
})
.then((response: any) => response.data)
.catch((error: any) => {
console.error('Failed to fetch plugin panels:', error);
.catch((_error: any) => {
console.error(`ERR: Failed to fetch plugin panels`);
return [];
});
}
@ -80,32 +85,13 @@ export function usePluginPanels({
};
}, [model, id, instance]);
// 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(`${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(`${props.plugin}-${props.name}`);
const isHidden: boolean = panelState[identifier] ?? true;
pluginData?.map((props: PluginUIFeature) => {
const iconName: string = props?.icon || 'plugin';
const identifier = identifierString(
`${props.plugin_name}-${props.key}`
);
const pluginContext: any = {
...contextData,
@ -114,19 +100,18 @@ export function usePluginPanels({
return {
name: identifier,
label: props.label,
label: props.title,
icon: <InvenTreeIcon icon={iconName as InvenTreeIconType} />,
content: (
<PluginPanelContent
pluginProps={props}
pluginFeature={props}
pluginContext={pluginContext}
/>
),
hidden: isHidden
)
};
}) ?? []
);
}, [panelState, pluginData, contextData]);
}, [pluginData, contextData]);
return pluginPanels;
}

View File

@ -52,7 +52,7 @@ export function usePluginUIFeature<UIFeatureT extends BaseUIFeature>({
.then((response: any) => response.data)
.catch((error: any) => {
console.error(
`Failed to fetch plugin ui features for feature "${featureType}":`,
`ERR: Failed to fetch plugin ui features for feature "${featureType}":`,
error
);
return [];
@ -70,21 +70,25 @@ export function usePluginUIFeature<UIFeatureT extends BaseUIFeature>({
}[]
>(() => {
return (
pluginData?.map((feature) => ({
options: feature.options,
func: (async (featureContext) => {
const func = await findExternalPluginFunction(
feature.source,
'getFeature'
);
if (!func) return;
pluginData?.map((feature) => {
return {
options: {
...feature
},
func: (async (featureContext) => {
const func = await findExternalPluginFunction(
feature.source,
'getFeature'
);
if (!func) return;
return func({
featureContext,
inventreeContext
});
}) as PluginUIFuncWithoutInvenTreeContextType<UIFeatureT>
})) || []
return func({
featureContext,
inventreeContext
});
}) as PluginUIFuncWithoutInvenTreeContextType<UIFeatureT>
};
}) || []
);
}, [pluginData, inventreeContext]);
}

View File

@ -82,21 +82,6 @@ export const link = style({
}
});
export const subLink = style({
width: '100%',
padding: `${vars.spacing.xs} ${vars.spacing.md}`,
borderRadius: vars.radiusDefault,
':hover': {
[vars.lightSelector]: { backgroundColor: vars.colors.gray[0] },
[vars.darkSelector]: { backgroundColor: vars.colors.dark[7] }
},
':active': {
color: vars.colors.defaultHover
}
});
export const docHover = style({
border: `1px dashed `
});
@ -106,24 +91,6 @@ export const layoutContent = style({
width: '100%'
});
export const layoutFooterLinks = style({
[vars.smallerThan('xs')]: {
marginTop: vars.spacing.md
}
});
export const layoutFooterInner = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: vars.spacing.xl,
paddingBottom: vars.spacing.xl,
[vars.smallerThan('xs')]: {
flexDirection: 'column'
}
});
export const tabs = style({
[vars.smallerThan('sm')]: {
display: 'none'

View File

@ -1,39 +0,0 @@
import { Trans } from '@lingui/macro';
import { Chip, Group, SimpleGrid, Text } from '@mantine/core';
import { DashboardItemProxy } from '../../components/DashboardItemProxy';
import { StylishText } from '../../components/items/StylishText';
import { dashboardItems } from '../../defaults/dashboardItems';
import { useLocalState } from '../../states/LocalState';
export default function Dashboard() {
const [autoupdate, toggleAutoupdate] = useLocalState((state) => [
state.autoupdate,
state.toggleAutoupdate
]);
return (
<>
<Group>
<StylishText>
<Trans>Dashboard</Trans>
</StylishText>
<Chip checked={autoupdate} onChange={() => toggleAutoupdate()}>
<Trans>Autoupdate</Trans>
</Chip>
</Group>
<Text>
<Trans>
This page is a replacement for the old start page with the same
information. This page will be deprecated and replaced by the home
page.
</Trans>
</Text>
<SimpleGrid cols={4} pt="md">
{dashboardItems.map((item) => (
<DashboardItemProxy key={item.id} {...item} autoupdate={autoupdate} />
))}
</SimpleGrid>
</>
);
}

View File

@ -1,63 +1,9 @@
import { Trans } from '@lingui/macro';
import { Title } from '@mantine/core';
import { lazy } from 'react';
import {
LayoutItemType,
WidgetLayout
} from '../../components/widgets/WidgetLayout';
import { LoadingItem } from '../../functions/loading';
import { useUserState } from '../../states/UserState';
const vals: LayoutItemType[] = [
{
i: 1,
val: (
<LoadingItem
item={lazy(() => import('../../components/widgets/GetStartedWidget'))}
/>
),
w: 12,
h: 6,
x: 0,
y: 0,
minH: 6
},
{
i: 2,
val: (
<LoadingItem
item={lazy(() => import('../../components/widgets/DisplayWidget'))}
/>
),
w: 3,
h: 3,
x: 0,
y: 7,
minH: 3
},
{
i: 4,
val: (
<LoadingItem
item={lazy(() => import('../../components/widgets/FeedbackWidget'))}
/>
),
w: 4,
h: 6,
x: 0,
y: 9
}
];
import DashboardLayout from '../../components/dashboard/DashboardLayout';
export default function Home() {
const [username] = useUserState((state) => [state.username()]);
return (
<>
<Title order={1}>
<Trans>Welcome to your Dashboard{username && `, ${username}`}</Trans>
</Title>
<WidgetLayout items={vals} />
<DashboardLayout />
</>
);
}

View File

@ -323,7 +323,7 @@ export default function CategoryDetail() {
panels={panels}
model={ModelType.partcategory}
instance={category}
id={category.pk}
id={category.pk ?? null}
/>
</Stack>
</InstanceDetail>

View File

@ -392,7 +392,7 @@ export default function Stock() {
pageKey="stocklocation"
panels={locationPanels}
model={ModelType.stocklocation}
id={location.pk}
id={location.pk ?? null}
instance={location}
/>
{transferStockItems.modal}

View File

@ -82,9 +82,6 @@ export const ReturnOrderDetail = Loadable(
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
export const Dashboard = Loadable(
lazy(() => import('./pages/Index/Dashboard'))
);
export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage')));
export const Notifications = Loadable(
@ -121,7 +118,6 @@ export const routes = (
<Route path="/" element={<LayoutComponent />} errorElement={<ErrorPage />}>
<Route index element={<Home />} />,
<Route path="home/" element={<Home />} />,
<Route path="dashboard/" element={<Dashboard />} />,
<Route path="notifications/*" element={<Notifications />} />,
<Route path="scan/" element={<Scan />} />,
<Route path="settings/">

View File

@ -11,6 +11,7 @@ import { UserProps } from './states';
export interface UserStateProps {
user: UserProps | undefined;
token: string | undefined;
userId: () => number | undefined;
username: () => string;
setUser: (newUser: UserProps) => void;
setToken: (newToken: string) => void;
@ -50,6 +51,10 @@ export const useUserState = create<UserStateProps>((set, get) => ({
set({ token: undefined });
setApiDefaults();
},
userId: () => {
const user: UserProps = get().user as UserProps;
return user.pk;
},
username: () => {
const user: UserProps = get().user as UserProps;

View File

@ -135,6 +135,14 @@ export default function InvenTreeTableHeader({
/>
</Boundary>
)}
{tableState.queryFilters.size > 0 && (
<Alert
color="yellow"
withCloseButton
title={t`Custom table filters are active`}
onClose={() => tableState.clearQueryFilters()}
></Alert>
)}
<Group justify="apart" grow wrap="nowrap">
<Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
@ -193,21 +201,6 @@ export default function InvenTreeTableHeader({
onToggleColumn={toggleColumn}
/>
)}
{tableState.queryFilters.size > 0 && (
<ActionIcon
variant="transparent"
color="red"
aria-label="table-clear-query-filters"
>
<Tooltip label={t`Clear custom query filters`}>
<IconFilterCancel
onClick={() => {
tableState.clearQueryFilters();
}}
/>
</Tooltip>
</ActionIcon>
)}
{tableProps.enableFilters && filters.length > 0 && (
<Indicator
size="xs"

View File

@ -115,10 +115,10 @@ export function BuildOrderTable({
const tableFilters: TableFilter[] = useMemo(() => {
let filters: TableFilter[] = [
{
name: 'active',
name: 'outstanding',
type: 'boolean',
label: t`Active`,
description: t`Show active orders`
label: t`Outstanding`,
description: t`Show outstanding orders`
},
{
name: 'status',

View File

@ -374,7 +374,7 @@ export default function PluginListTable() {
{deletePluginModal.modal}
{activatePluginModal.modal}
<DetailDrawer
title={t`Plugin Detail`}
title={t`Plugin Detail` + ' - ' + selectedPlugin?.name}
size={'65%'}
renderContent={(pluginKey) => {
if (!pluginKey) return;

View File

@ -1,7 +1,7 @@
import { Trans, t } from '@lingui/macro';
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import { IconFileCode } from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
@ -27,6 +27,7 @@ import {
} from '../../components/plugins/PluginUIFeatureTypes';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { identifierString } from '../../functions/conversion';
import { GetIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { useFilters } from '../../hooks/UseFilter';
@ -94,7 +95,12 @@ export function TemplateDrawer({
featureType: 'template_editor',
context: { template_type: modelType, template_model: template?.model_type! }
});
/**
* List of available editors for the template
*/
const editors = useMemo(() => {
// Always include the built-in code editor
const editors = [CodeEditor];
if (!template) {
@ -102,15 +108,16 @@ export function TemplateDrawer({
}
editors.push(
...(extraEditors?.map(
(editor) =>
({
key: editor.options.key,
name: editor.options.title,
icon: GetIcon(editor.options.icon),
component: getPluginTemplateEditor(editor.func, template)
} as Editor)
) || [])
...(extraEditors?.map((editor) => {
return {
key: identifierString(
`${editor.options.plugin_name}-${editor.options.key}`
),
name: editor.options.title,
icon: GetIcon(editor.options.icon || 'plugin'),
component: getPluginTemplateEditor(editor.func, template)
} as Editor;
}) || [])
);
return editors;
@ -135,7 +142,7 @@ export function TemplateDrawer({
({
key: preview.options.key,
name: preview.options.title,
icon: GetIcon(preview.options.icon),
icon: GetIcon(preview.options.icon || 'plugin'),
component: getPluginTemplatePreview(preview.func, template)
} as PreviewArea)
) || [])

View File

@ -140,7 +140,7 @@ export function UserDrawer({
<Text ml={'md'}>
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
<List>
{userDetail?.groups?.map((group) => (
{userDetail?.groups?.map((group: any) => (
<List.Item key={group.pk}>
<DetailDrawerLink
to={`../group-${group.pk}`}

View File

@ -33,9 +33,8 @@ export const doQuickLogin = async (
await page.goto(`${url}/login/?login=${username}&password=${password}`);
await page.waitForURL('**/platform/home');
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
.waitFor();
await page.getByText(/InvenTree Demo Server/).waitFor();
};
export const doLogout = async (page) => {

View File

@ -52,12 +52,12 @@ test('Modals as admin', async ({ page }) => {
await page.goto('./platform/');
// qr code modal
await page.getByRole('button', { name: 'Open QR code scanner' }).click();
// Barcode scanning window
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByRole('banner').getByRole('button').click();
await page.getByRole('button', { name: 'Open QR code scanner' }).click();
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.getByRole('button', { name: 'Close modal' }).click();
await page.getByRole('button', { name: 'Open QR code scanner' }).click();
await page.getByRole('button', { name: 'Open Barcode Scanner' }).click();
await page.waitForTimeout(500);
await page.getByRole('banner').getByRole('button').click();
});

View File

@ -0,0 +1,64 @@
import { test } from '../baseFixtures.js';
import { doQuickLogin } from '../login.js';
import { setPluginState } from '../settings.js';
test('Pages - Dashboard - Basic', async ({ page }) => {
await doQuickLogin(page);
await page.getByText('Use the menu to add widgets').waitFor();
// Let's add some widgets
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
await page.getByLabel('dashboard-widgets-filter-input').fill('overdue order');
await page.getByLabel('add-widget-ovr-so').click();
await page.getByLabel('add-widget-ovr-po').click();
await page.getByLabel('dashboard-widgets-filter-clear').click();
// Close the widget
await page.getByRole('banner').getByRole('button').click();
await page.waitForTimeout(500);
// Check that the widgets are visible
await page.getByText('Overdue Sales Orders').waitFor();
await page.getByText('Overdue Purchase Orders').waitFor();
// Let's remove one of the widgets
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Remove Widgets' }).click();
await page.getByLabel('remove-dashboard-item-ovr-so').click();
// Accept the layout
await page.getByLabel('dashboard-accept-layout').click();
});
test('Pages - Dashboard - Plugins', async ({ page, request }) => {
// Ensure that the "SampleUI" plugin is enabled
await setPluginState({
request,
plugin: 'sampleui',
state: true
});
await doQuickLogin(page);
// Add a dashboard widget from the SampleUI plugin
await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
await page.getByLabel('dashboard-widgets-filter-input').fill('sample');
// Add the widget
await page.getByLabel(/add-widget-p-sampleui-sample-/).click();
// Close the widget
await page.getByRole('banner').getByRole('button').click();
await page.waitForTimeout(500);
// Check that the widget is visible
await page.getByRole('heading', { name: 'Sample Dashboard Item' }).waitFor();
await page.getByText('Hello world! This is a sample').waitFor();
});

View File

@ -1,13 +0,0 @@
import { test } from '../baseFixtures.js';
import { doQuickLogin } from '../login.js';
test('Pages - Index - Dashboard', async ({ page }) => {
await doQuickLogin(page);
// Dashboard auto update
await page.getByRole('tab', { name: 'Dashboard' }).click();
await page.getByText('Autoupdate').click();
await page.waitForTimeout(500);
await page.getByText('Autoupdate').click();
await page.getByText('This page is a replacement').waitFor();
});

View File

@ -2,7 +2,76 @@ import { test } from '../baseFixtures';
import { baseUrl } from '../defaults';
import { doQuickLogin } from '../login';
test('Pages - Part - Locking', async ({ page }) => {
/**
* CHeck each panel tab for the "Parts" page
*/
test('Parts - Tabs', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Parts' }).click();
await page.waitForURL('**/platform/part/category/index/details');
await page.goto(`${baseUrl}/part/category/index/parts`);
await page.getByText('1551ABK').click();
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('tab', { name: 'Used In' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Scheduling' }).click();
await page.getByRole('tab', { name: 'Stock History' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Related Parts' }).click();
// Related Parts
await page.getByText('1551ACLR').click();
await page.getByRole('tab', { name: 'Part Details' }).click();
await page.getByRole('tab', { name: 'Parameters' }).click();
await page
.getByRole('tab', { name: 'Part Details' })
.locator('xpath=..')
.getByRole('tab', { name: 'Stock', exact: true })
.click();
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('tab', { name: 'Used In' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.goto(`${baseUrl}/part/category/index/parts`);
await page.getByText('Blue Chair').click();
await page.getByRole('tab', { name: 'Bill of Materials' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
});
test('Parts - Manufacturer Parts', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/84/manufacturers`);
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('Hammond Manufacturing').click();
await page.getByRole('tab', { name: 'Parameters' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByText('1551ACLR - 1551ACLR').waitFor();
});
test('Parts - Supplier Parts', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/15/suppliers`);
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click(); //
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
});
test('Parts - Locking', async ({ page }) => {
await doQuickLogin(page);
// Navigate to a known assembly which is *not* locked
@ -28,7 +97,7 @@ test('Pages - Part - Locking', async ({ page }) => {
await page.getByText('Part parameters cannot be').waitFor();
});
test('Pages - Part - Allocations', async ({ page }) => {
test('Parts - Allocations', async ({ page }) => {
await doQuickLogin(page);
// Let's look at the allocations for a single stock item
@ -57,7 +126,7 @@ test('Pages - Part - Allocations', async ({ page }) => {
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
});
test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
test('Parts - Pricing (Nothing, BOM)', async ({ page }) => {
await doQuickLogin(page);
// Part with no history
@ -106,7 +175,7 @@ test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
await page.waitForURL('**/part/98/**');
});
test('Pages - Part - Pricing (Supplier)', async ({ page }) => {
test('Parts - Pricing (Supplier)', async ({ page }) => {
await doQuickLogin(page);
// Part
@ -132,7 +201,7 @@ test('Pages - Part - Pricing (Supplier)', async ({ page }) => {
// await page.waitForURL('**/purchasing/supplier-part/697/');
});
test('Pages - Part - Pricing (Variant)', async ({ page }) => {
test('Parts - Pricing (Variant)', async ({ page }) => {
await doQuickLogin(page);
// Part
@ -158,7 +227,7 @@ test('Pages - Part - Pricing (Variant)', async ({ page }) => {
await page.waitForURL('**/part/109/**');
});
test('Pages - Part - Pricing (Internal)', async ({ page }) => {
test('Parts - Pricing (Internal)', async ({ page }) => {
await doQuickLogin(page);
// Part
@ -183,7 +252,7 @@ test('Pages - Part - Pricing (Internal)', async ({ page }) => {
await page.getByText('Part *M2x4 SHCSSocket head').click();
});
test('Pages - Part - Pricing (Purchase)', async ({ page }) => {
test('Parts - Pricing (Purchase)', async ({ page }) => {
await doQuickLogin(page);
// Part
@ -205,7 +274,7 @@ test('Pages - Part - Pricing (Purchase)', async ({ page }) => {
await page.getByText('2022-04-29').waitFor();
});
test('Pages - Part - Attachments', async ({ page }) => {
test('Parts - Attachments', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/attachments`);
@ -227,7 +296,7 @@ test('Pages - Part - Attachments', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Pages - Part - Parameters', async ({ page }) => {
test('Parts - Parameters', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/parameters`);
@ -254,7 +323,7 @@ test('Pages - Part - Parameters', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Pages - Part - Notes', async ({ page }) => {
test('Parts - Notes', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/notes`);
@ -276,7 +345,7 @@ test('Pages - Part - Notes', async ({ page }) => {
await page.getByLabel('Close Editor').waitFor();
});
test('Pages - Part - 404', async ({ page }) => {
test('Parts - 404', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/99999/`);
@ -286,7 +355,7 @@ test('Pages - Part - 404', async ({ page }) => {
await page.evaluate(() => console.clear());
});
test('Pages - Part - Revision', async ({ page }) => {
test('Parts - Revision', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/906/details`);

View File

@ -14,9 +14,7 @@ test('Basic Login Test', async ({ page }) => {
await page.goto(baseUrl);
await page.waitForURL('**/platform');
await page
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
.click();
await page.getByText('InvenTree Demo Server').waitFor();
// Check that the username is provided
await page.getByText(user.username);
@ -47,9 +45,7 @@ test('Quick Login Test', async ({ page }) => {
await page.goto(baseUrl);
await page.waitForURL('**/platform');
await page
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
.click();
await page.getByText('InvenTree Demo Server').waitFor();
// Logout (via URL)
await page.goto(`${baseUrl}/logout/`);

View File

@ -1,34 +1,16 @@
import { systemKey, test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('Quick Command', async ({ page }) => {
await doQuickLogin(page);
// Open Spotlight with Keyboard Shortcut
await page.locator('body').press(`${systemKey}+k`);
await page.waitForTimeout(200);
await page
.getByRole('button', { name: 'Go to the InvenTree dashboard' })
.click();
await page.locator('p').filter({ hasText: 'Dashboard' }).waitFor();
await page.waitForURL('**/platform/dashboard');
// Open Spotlight with Button
await page.getByLabel('open-spotlight').click();
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
.click();
await page.waitForURL('**/platform');
// Open Spotlight with Keyboard Shortcut and Search
await page.locator('body').press(`${systemKey}+k`);
await page.waitForTimeout(200);
await page.getByPlaceholder('Search...').fill('Dashboard');
await page.getByPlaceholder('Search...').press('Tab');
await page.getByPlaceholder('Search...').press('Enter');
await page.waitForURL('**/platform/dashboard');
await page.waitForURL('**/platform/home');
});
test('Quick Command - No Keys', async ({ page }) => {
@ -36,23 +18,31 @@ test('Quick Command - No Keys', async ({ page }) => {
// Open Spotlight with Button
await page.getByLabel('open-spotlight').click();
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
.getByRole('button', { name: 'Dashboard Go to the InvenTree' })
.click();
await page.waitForURL('**/platform');
await page.getByText('InvenTree Demo Server').waitFor();
await page.waitForURL('**/platform/home');
// Use navigation menu
await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'Open Navigation Open the main' })
.click();
// assert the nav headers are visible
await page.getByRole('heading', { name: 'Navigation' }).waitFor();
await page.getByRole('heading', { name: 'Pages' }).waitFor();
await page.getByRole('heading', { name: 'Documentation' }).waitFor();
await page.getByRole('heading', { name: 'About' }).waitFor();
await page.waitForTimeout(1000);
// assert the nav headers are visible
await page.getByText('Navigation').waitFor();
await page.getByText('Documentation').waitFor();
await page.getByText('About').first().waitFor();
await page
.getByRole('button', { name: 'Notifications', exact: true })
.waitFor();
await page.getByRole('button', { name: 'Dashboard', exact: true }).waitFor();
// close the nav
await page.keyboard.press('Escape');
// use server info
@ -65,7 +55,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.getByRole('cell', { name: 'Instance Name' }).waitFor();
await page.getByRole('button', { name: 'Dismiss' }).click();
await page.waitForURL('**/platform');
await page.waitForURL('**/platform/home');
// use license info
await page.getByLabel('open-spotlight').click();

View File

@ -2,72 +2,6 @@ import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('Parts', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Parts' }).click();
await page.waitForURL('**/platform/part/category/index/details');
await page.goto(`${baseUrl}/part/category/index/parts`);
await page.getByText('1551ABK').click();
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('tab', { name: 'Used In' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Scheduling' }).click();
await page.getByRole('tab', { name: 'Stock History' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByRole('tab', { name: 'Notes' }).click();
await page.getByRole('tab', { name: 'Related Parts' }).click();
// Related Parts
await page.getByText('1551ACLR').click();
await page.getByRole('tab', { name: 'Part Details' }).click();
await page.getByRole('tab', { name: 'Parameters' }).click();
await page
.getByRole('tab', { name: 'Part Details' })
.locator('xpath=..')
.getByRole('tab', { name: 'Stock', exact: true })
.click();
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('tab', { name: 'Used In' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.goto(`${baseUrl}/part/category/index/parts`);
await page.getByText('Blue Chair').click();
await page.getByRole('tab', { name: 'Bill of Materials' }).click();
await page.getByRole('tab', { name: 'Build Orders' }).click();
});
test('Parts - Manufacturer Parts', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/84/manufacturers`);
await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('Hammond Manufacturing').click();
await page.getByRole('tab', { name: 'Parameters' }).click();
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('tab', { name: 'Attachments' }).click();
await page.getByText('1551ACLR - 1551ACLR').waitFor();
});
test('Parts - Supplier Parts', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/15/suppliers`);
await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click(); //
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('tab', { name: 'Pricing' }).click();
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
});
test('Sales', async ({ page }) => {
await doQuickLogin(page);
@ -122,13 +56,13 @@ test('Sales', async ({ page }) => {
test('Scanning', async ({ page }) => {
await doQuickLogin(page);
await page.getByLabel('Homenav').click();
await page.getByLabel('navigation-menu').click();
await page.getByRole('button', { name: 'System Information' }).click();
await page.locator('button').filter({ hasText: 'Dismiss' }).click();
await page.getByRole('link', { name: 'Scanning' }).click();
await page.waitForTimeout(200);
await page.locator('.mantine-Overlay-root').click();
await page.getByLabel('navigation-menu').click();
await page.getByRole('button', { name: 'Scan Barcode' }).click();
await page.getByPlaceholder('Select input method').click();
await page.getByRole('option', { name: 'Manual input' }).click();
await page.getByPlaceholder('Enter item serial or data').click();
@ -140,40 +74,6 @@ test('Scanning', async ({ page }) => {
await page.getByRole('option', { name: 'Manual input' }).click();
});
test('Language / Color', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('button', { name: 'Ally Access' }).click();
await page.getByRole('menuitem', { name: 'Logout' }).click();
await page.getByRole('button', { name: 'Send me an email' }).click();
await page.getByRole('button').nth(3).click();
await page.getByLabel('Select language').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Benutzername und Passwort' }).click();
await page.getByPlaceholder('Ihr Benutzername').click();
await page.getByPlaceholder('Ihr Benutzername').fill('admin');
await page.getByPlaceholder('Ihr Benutzername').press('Tab');
await page.getByPlaceholder('Dein Passwort').fill('inventree');
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForTimeout(200);
await page
.locator('span')
.filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
.getByRole('button')
.click();
await page
.locator('span')
.filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
.getByRole('button')
.click();
await page.getByRole('button', { name: "InvenTree's Logo" }).first().click();
await page.getByRole('tab', { name: 'Dashboard' }).click();
await page.waitForURL('**/platform/dashboard');
});
test('Company', async ({ page }) => {
await doQuickLogin(page);

View File

@ -14,6 +14,8 @@ test('Plugins - Panels', async ({ page, request }) => {
value: true
});
await page.waitForTimeout(500);
// Ensure that the SampleUI plugin is enabled
await setPluginState({
request,
@ -21,28 +23,34 @@ test('Plugins - Panels', async ({ page, request }) => {
state: true
});
await page.waitForTimeout(500);
// 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();
// Allow time for the plugin panels to load (they are loaded asynchronously)
await page.waitForTimeout(1000);
// 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.waitForTimeout(500);
await page.getByRole('tab', { name: 'Dynamic Part Panel' }).click();
await page.getByText('Error occurred while loading plugin content').waitFor();
await page.getByRole('tab', { name: 'Dynamic Panel' }).click();
await page.waitForTimeout(500);
await page.getByText('Instance ID: 69');
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.waitForTimeout(500);
await page.getByText('This content has been rendered by a custom plugin');
// Disable the plugin, and ensure it is no longer visible

View File

@ -3,7 +3,45 @@ import { apiUrl, baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
import { setSettingState } from './settings.js';
test('Admin', async ({ page }) => {
/**
* Adjust language and color settings
*/
test('Settings - Language / Color', async ({ page }) => {
await doQuickLogin(page);
await page.getByRole('button', { name: 'Ally Access' }).click();
await page.getByRole('menuitem', { name: 'Logout' }).click();
await page.getByRole('button', { name: 'Send me an email' }).click();
await page.getByRole('button').nth(3).click();
await page.getByLabel('Select language').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Benutzername und Passwort' }).click();
await page.getByPlaceholder('Ihr Benutzername').click();
await page.getByPlaceholder('Ihr Benutzername').fill('admin');
await page.getByPlaceholder('Ihr Benutzername').press('Tab');
await page.getByPlaceholder('Dein Passwort').fill('inventree');
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForTimeout(200);
// Note: changes to the dashboard have invalidated these tests (for now)
// await page
// .locator('span')
// .filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
// .getByRole('button')
// .click();
// await page
// .locator('span')
// .filter({ hasText: 'AnzeigeneinstellungenFarbmodusSprache' })
// .getByRole('button')
// .click();
await page.getByRole('tab', { name: 'Dashboard' }).click();
await page.waitForURL('**/platform/home');
});
test('Settings - Admin', async ({ page }) => {
// Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree');
@ -86,7 +124,7 @@ test('Admin', async ({ page }) => {
await page.getByRole('button', { name: 'Submit' }).click();
});
test('Admin - Barcode History', async ({ page, request }) => {
test('Settings - Admin - Barcode History', async ({ page, request }) => {
// Login with admin credentials
await doQuickLogin(page, 'admin', 'inventree');
@ -123,7 +161,7 @@ test('Admin - Barcode History', async ({ page, request }) => {
});
});
test('Admin - Unauthorized', async ({ page }) => {
test('Settings - Admin - Unauthorized', async ({ page }) => {
// Try to access "admin" page with a non-staff user
await doQuickLogin(page, 'allaccess', 'nolimits');