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:
parent
c4031dba7f
commit
18e5b0df58
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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(
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
),
|
||||
)
|
||||
]
|
||||
|
@ -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 []
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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>
|
@ -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'}
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
@ -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>
|
||||
`;
|
||||
}
|
@ -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) {
|
||||
|
||||
|
@ -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>";
|
||||
}
|
@ -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>";
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
328
src/frontend/src/components/dashboard/DashboardLayout.tsx
Normal file
328
src/frontend/src/components/dashboard/DashboardLayout.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
135
src/frontend/src/components/dashboard/DashboardMenu.tsx
Normal file
135
src/frontend/src/components/dashboard/DashboardMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
84
src/frontend/src/components/dashboard/DashboardWidget.tsx
Normal file
84
src/frontend/src/components/dashboard/DashboardWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
130
src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx
Normal file
130
src/frontend/src/components/dashboard/DashboardWidgetDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
180
src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx
Normal file
180
src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx
Normal 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()
|
||||
];
|
||||
}
|
@ -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)
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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)
|
||||
};
|
||||
}
|
143
src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
Normal file
143
src/frontend/src/components/dashboard/widgets/NewsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
)
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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}>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 = () => {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
105
src/frontend/src/components/plugins/RemoteComponent.tsx
Normal file
105
src/frontend/src/components/plugins/RemoteComponent.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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'
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
});
|
||||
|
||||
|
@ -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: {}
|
||||
}
|
||||
];
|
@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
@ -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'
|
||||
}
|
||||
};
|
@ -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
|
||||
|
@ -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
|
||||
|
116
src/frontend/src/hooks/UseDashboardItems.tsx
Normal file
116
src/frontend/src/hooks/UseDashboardItems.tsx
Normal 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
|
||||
};
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
|
14
src/frontend/src/hooks/UseInstanceName.tsx
Normal file
14
src/frontend/src/hooks/UseInstanceName.tsx
Normal 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]);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -323,7 +323,7 @@ export default function CategoryDetail() {
|
||||
panels={panels}
|
||||
model={ModelType.partcategory}
|
||||
instance={category}
|
||||
id={category.pk}
|
||||
id={category.pk ?? null}
|
||||
/>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
|
@ -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}
|
||||
|
@ -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/">
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
) || [])
|
||||
|
@ -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}`}
|
||||
|
@ -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) => {
|
||||
|
@ -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();
|
||||
});
|
||||
|
64
src/frontend/tests/pages/pui_dashboard.spec.ts
Normal file
64
src/frontend/tests/pages/pui_dashboard.spec.ts
Normal 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();
|
||||
});
|
@ -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();
|
||||
});
|
@ -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`);
|
||||
|
@ -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/`);
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user