2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 3125 additions and 2126 deletions

View File

@ -4,19 +4,104 @@ title: User Interface Mixin
## User Interface Mixin
The *User Interface* mixin class provides a set of methods to implement custom functionality for the InvenTree web interface.
The `UserInterfaceMixin` class provides a set of methods to implement custom functionality for the InvenTree web interface.
### Enable User Interface Mixin
To enable user interface plugins, the global setting `ENABLE_PLUGINS_INTERFACE` must be enabled, in the [plugin settings](../../settings/global.md#plugin-settings).
## Plugin Context
## Custom UI Features
When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
The InvenTree user interface functionality can be extended in various ways using plugins. Multiple types of user interface *features* can be added to the InvenTree user interface.
{{ includefile("src/frontend/src/components/plugins/PluginContext.tsx", title="Plugin Context", fmt="javascript") }}
The entrypoint for user interface plugins is the `UserInterfaceMixin` class, which provides a number of methods which can be overridden to provide custom functionality. The `get_ui_features` method is used to extract available user interface features from the plugin:
## Custom Panels
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_features
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []
Note here that the `get_ui_features` calls other methods to extract the available features from the plugin, based on the requested feature type. These methods can be overridden to provide custom functionality.
!!! info "Implementation"
Your custom plugin does not need to override the `get_ui_features` method. Instead, override one of the other methods to provide custom functionality.
### UIFeature Return Type
The `get_ui_features` method should return a list of `UIFeature` objects, which define the available user interface features for the plugin. The `UIFeature` class is defined as follows:
::: plugin.base.ui.mixins.UIFeature
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []
Note that the *options* field contains fields which may be specific to a particular feature type - read the documentation below on each feature type for more information.
### Dynamic Feature Loading
Each of the provided feature types can be loaded dynamically by the plugin, based on the information provided in the API request. For example, the plugin can choose to show or hide a particular feature based on the user permissions, or the current state of the system.
For examples of this dynamic feature loading, refer to the [sample plugin](#sample-plugin) implementation which demonstrates how to dynamically load custom panels based on the provided context.
### Javascript Source Files
The rendering function for the custom user interface features expect that the plugin provides a Javascript source file which contains the necessary code to render the custom content. The path to this file should be provided in the `source` field of the `UIFeature` object.
Note that the `source` field can include the name of the function to be called (if this differs from the expected default function name).
For example:
```
"source": "/static/plugins/my_plugin/my_plugin.js:my_custom_function"
```
## Available UI Feature Types
The following user interface feature types are available:
### Dashboard Items
The InvenTree dashboard is a collection of "items" which are displayed on the main dashboard page. Custom dashboard items can be added to the dashboard by implementing the `get_ui_dashboard_items` method:
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_dashboard_items
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []
#### Dashboard Item Options
The *options* field in the returned `UIFeature` object can contain the following properties:
::: plugin.base.ui.mixins.CustomDashboardItemOptions
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []
#### Source Function
The frontend code expects a path to a javascript file containing a function named `renderDashboardItem` which will be called to render the custom dashboard item. Note that this function name can be overridden by appending the function name in the `source` field of the `UIFeature` object.
#### Example
Refer to the [sample plugin](#sample-plugin) for an example of how to implement server side rendering for custom panels.
### Panels
Many of the pages in the InvenTree web interface are built using a series of "panels" which are displayed on the page. Custom panels can be added to these pages, by implementing the `get_ui_panels` method:
@ -29,71 +114,11 @@ Many of the pages in the InvenTree web interface are built using a series of "pa
summary: False
members: []
The custom panels can display content which is generated either on the server side, or on the client side (see below).
#### Panel Options
### Server Side Rendering
The *options* field in the returned `UIFeature` object can contain the following properties:
The panel content can be generated on the server side, by returning a 'content' attribute in the response. This 'content' attribute is expected to be raw HTML, and is rendered directly into the page. This is particularly useful for displaying static content.
Server-side rendering is simple to implement, and can make use of the powerful Django templating system.
Refer to the [sample plugin](#sample-plugin) for an example of how to implement server side rendering for custom panels.
**Advantages:**
- Simple to implement
- Can use Django templates to render content
- Has access to the full InvenTree database, and content not available on the client side (via the API)
**Disadvantages:**
- Content is rendered on the server side, and cannot be updated without a page refresh
- Content is not interactive
### Client Side Rendering
The panel content can also be generated on the client side, by returning a 'source' attribute in the response. This 'source' attribute is expected to be a URL which points to a JavaScript file which will be loaded by the client.
Refer to the [sample plugin](#sample-plugin) for an example of how to implement client side rendering for custom panels.
#### Panel Render Function
The JavaScript file must implement a `renderPanel` function, which is called by the client when the panel is rendered. This function is passed two parameters:
- `target`: The HTML element which the panel content should be rendered into
- `context`: A dictionary of context data which can be used to render the panel content
**Example**
```javascript
export function renderPanel(target, context) {
target.innerHTML = "<h1>Hello, world!</h1>";
}
```
#### Panel Visibility Function
The JavaScript file can also implement a `isPanelHidden` function, which is called by the client to determine if the panel is displayed. This function is passed a single parameter, *context* - which is the same as the context data passed to the `renderPanel` function.
The `isPanelHidden` function should return a boolean value, which determines if the panel is displayed or not, based on the context data.
If the `isPanelHidden` function is not implemented, the panel will be displayed by default.
**Example**
```javascript
export function isPanelHidden(context) {
// Only visible for active parts
return context.model == 'part' && context.instance?.active;
}
```
## Custom UI Functions
User interface plugins can also provide additional user interface functions. These functions can be provided via the `get_ui_features` method:
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_features
::: plugin.base.ui.mixins.CustomPanelOptions
options:
show_bases: False
show_root_heading: False
@ -102,36 +127,55 @@ User interface plugins can also provide additional user interface functions. The
summary: False
members: []
::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin.get_ui_features
#### Source Function
The frontend code expects a path to a javascript file containing a function named `renderPanel` which will be called to render the custom panel. Note that this function name can be overridden by appending the function name in the `source` field of the `UIFeature` object.
#### Example
Refer to the [sample plugin](#sample-plugin) for an example of how to implement server side rendering for custom panels.
### Template Editors
The `get_ui_template_editors` feature type can be used to provide custom template editors.
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_template_editors
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
Currently the following functions can be extended:
### Template editors
The `template_editor` feature type can be used to provide custom template editors.
**Example:**
{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }}
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []
### Template previews
The `template_preview` feature type can be used to provide custom template previews. For an example see:
The `get_ui_template_previews` feature type can be used to provide custom template previews:
**Example:**
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_template_previews
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []
{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }}
## Plugin Context
When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
{{ includefile("src/frontend/src/components/plugins/PluginContext.tsx", title="Plugin Context", fmt="javascript") }}
This context data can be used to provide additional information to the rendering functions, and can be used to dynamically render content based on the current state of the system.
### Additional Context
Note that additional context can be passed to the rendering functions by adding additional key-value pairs to the `context` field in the `UIFeature` return type (provided by the backend via the API). This field is optional, and can be used at the discretion of the plugin developer.
## Sample Plugin
A sample plugin which implements custom user interface functionality is provided in the InvenTree source code:
A sample plugin which implements custom user interface functionality is provided in the InvenTree source code, which provides a full working example of how to implement custom user interface functionality.
::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin
options:

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