2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

[PUI] Dashboard refactor (#8278)

* Refactor plugin components into <RemoteComponent />

* Clean up footer

* Allow BuildOrder list to be sorted by 'outstanding'

* Fix model name

* Update BuildOrderTable filter

* Add StockItemTable column

* Working towards new dashboard

* Cleanup unused imports

* Updates: Now rendering some custom widgets

* Define icons for model types

* Add icon

* Cleanup / refactor / delete

- Complete transfer of files into new structure

* Follow link for query count widgets

* Add some more widgets to the library

* Remove old dashboard link in header

* Remove feedback widget

* Bump API version

* Remove test widget

* Rename "Home" -> "Dashboard"

* Add some more widgets

* Pass 'editable' property through to widgets

* Cleanup

* Add drawer for selecting new widgets

* Allow different layouts per user on the same machine

* Fixes

* Add ability to *remove* widgets

* Add helpful button

* Add a keyboard shortcut

* Refactoring

* Add backend code for serving custom dashboard items

* Load dashboard items from plugins

* Tweak for dashboard item API query

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

* Add message if no dashboard widgets are displayed

* Refactoring main navigation menu

- Group into sections
- Cleanup / consolidation
- General refactoring

* Remove playground

* Add backend field for storing dashboard layout

* Add extra type definitions for UseInstance

* Manual labels for builtin dashboard items

- Otherwise they will change with translation locale

* Shorten labels for more plugins

* Adjust DashboardMenu

* Reduce stored data

* Add widget filter by text

* Remove back-end settings

* Update playwright tests for dashboard

* Updated tests

* Refactor backend API for fetching plugin features

* Further fixes for back-end code

* More back-end fixes

* Refactor frontend:

- Custom panels
- Custom dashboard items

* Further backend fixes

* Yet more backend fixes

- Improve error handling

* Fix for custom plugin settings rendering

* Enable plugin panels for part index and stock index pages

* Cleanup

* Fix nav menu

* Update typing

* Helper func to return all plugin settings as a dict

* Update API version date

* Fix for UseInstancea

* typing fix

* Tweak layout callbacks

* Pass query parameters through to navigation functions

* Improve custom query display

* Add "news" widget

* Ensure links are prepended with base URL on receipt

* Update NewsWidget

* Bug fix

* Refactor template editor tests

* Refactor unit testing for test_ui_panels

* Unit test for dashboard item API endpoint

* Update comment

* Adjust playwright tests

* More playwright fixes

* Hide barcode scanning options if disabled

* Tweak dashboard widget

* Fix custom panel title

* Update documentation around UIMixin class

* Cleanup

* Additional docs

* Add icon def for 'error' ModelType

* Add error boundary to TemplateEditor component

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

* Tweak error messages

* API unit test fixes

* Unit test fix

* More unit test fixes

* Playwright test tweaks

* Adjust error messages
This commit is contained in:
Oliver 2024-11-02 08:48:29 +11:00 committed by GitHub
parent c4031dba7f
commit 18e5b0df58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 3125 additions and 2126 deletions

View File

@ -4,19 +4,104 @@ title: User Interface Mixin
## User Interface Mixin ## 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 ### 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). 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: 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 summary: False
members: [] 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. ::: plugin.base.ui.mixins.CustomPanelOptions
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
options: options:
show_bases: False show_bases: False
show_root_heading: False show_root_heading: False
@ -102,36 +127,55 @@ User interface plugins can also provide additional user interface functions. The
summary: False summary: False
members: [] 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: options:
show_bases: False show_bases: False
show_root_heading: False show_root_heading: False
show_root_toc_entry: False show_root_toc_entry: False
show_source: True show_sources: True
summary: False
members: [] 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") }}
### Template previews ### 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 ## 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 ::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin
options: options:

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v276 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8403
- Adds 'destination' field to the PurchaseOrder model and API endpoints - Adds 'destination' field to the PurchaseOrder model and API endpoints

View File

@ -40,6 +40,9 @@ class BuildFilter(rest_filters.FilterSet):
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') 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): def filter_active(self, queryset, name, value):
"""Filter the queryset to either include or exclude orders which are active.""" """Filter the queryset to either include or exclude orders which are active."""
if str2bool(value): if str2bool(value):

View File

@ -451,6 +451,10 @@ class BuildTest(BuildAPITest):
# Now, let's delete each build output individually via the API # Now, let's delete each build output individually via the API
outputs = bo.build_outputs.all() 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}) delete_url = reverse('api-build-output-delete', kwargs={'pk': 1})
response = self.post( response = self.post(

View File

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

View File

@ -196,7 +196,9 @@ class PurchaseOrderMixin:
"""Return the annotated queryset for this endpoint.""" """Return the annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) 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) queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
@ -671,7 +673,9 @@ class SalesOrderMixin:
"""Return annotated queryset for this endpoint.""" """Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) 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) queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
@ -1244,7 +1248,9 @@ class ReturnOrderMixin:
"""Return annotated queryset for this endpoint.""" """Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) 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) queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)

View File

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

View File

@ -13,47 +13,6 @@ from InvenTree.exceptions import log_error
from plugin import registry 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): class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features.""" """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 # Extract all plugins from the registry which provide custom ui features
for _plugin in registry.with_mixin('ui', active=True): for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out # Allow plugins to fill this data out
try:
plugin_features = _plugin.get_ui_features( plugin_features = _plugin.get_ui_features(
feature, request.query_params, request 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: if plugin_features and type(plugin_features) is list:
for _feature in plugin_features: 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( # Ensure base fields are strings
UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data 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 = [ ui_plugins_api_urls = [
path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'),
path( path(
'features/<str:feature>/', 'features/<str:feature>/',
PluginUIFeatureList.as_view(), PluginUIFeatureList.as_view(),
name='api-plugin-ui-feature-list', name='api-plugin-ui-feature-list',
), )
] ]

View File

@ -11,43 +11,59 @@ from rest_framework.request import Request
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
class CustomPanel(TypedDict): # List of supported feature types
"""Type definition for a custom panel. FeatureType = Literal[
'dashboard', # Custom dashboard items
Attributes: 'panel', # Custom panels
name: The name of the panel (required, used as a DOM identifier). 'template_editor', # Custom template editor
label: The label of the panel (required, human readable). 'template_preview', # Custom template preview
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']
class UIFeature(TypedDict): class UIFeature(TypedDict):
"""Base type definition for a ui feature. """Base type definition for a ui feature.
Attributes: 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) 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) 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 feature_type: FeatureType
options: dict options: dict
context: dict
source: str 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: class UserInterfaceMixin:
"""Plugin mixin class which handles injection of custom elements into the front-end interface. """Plugin mixin class which handles injection of custom elements into the front-end interface.
@ -65,48 +81,85 @@ class UserInterfaceMixin:
super().__init__() super().__init__()
self.add_mixin('ui', True, __class__) # type: ignore 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( def get_ui_features(
self, feature_type: FeatureType, context: dict, request: Request self, feature_type: FeatureType, context: dict, request: Request, **kwargs
) -> list[UIFeature]: ) -> list[UIFeature]:
"""Return a list of custom features to be injected into the UI. """Return a list of custom features to be injected into the UI.
Arguments: Arguments:
feature_type: The type of feature being requested 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) request: HTTPRequest object (including user information)
Returns: Returns:
list: A list of custom UIFeature dicts to be injected into the UI 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 # Default implementation returns an empty list
return [] return []

View File

@ -5,68 +5,60 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers 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): class PluginUIFeatureSerializer(serializers.Serializer):
"""Serializer for a plugin ui feature.""" """Serializer for a plugin ui feature."""
class Meta: class Meta:
"""Meta for serializer.""" """Meta for serializer."""
fields = ['feature_type', 'options', 'source'] fields = [
'plugin_name',
'feature_type',
'key',
'title',
'description',
'icon',
'options',
'context',
'source',
]
# Required fields # 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( feature_type = serializers.CharField(
label=_('Feature Type'), required=True, allow_blank=False 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( source = serializers.CharField(
label=_('Feature Source (javascript)'), required=True, allow_blank=False label=_('Feature Source (javascript)'), required=True, allow_blank=False

View File

@ -33,7 +33,60 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
plugins = registry.with_mixin('ui') plugins = registry.with_mixin('ui')
self.assertGreater(len(plugins), 0) 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.""" """Test that the sample UI plugin provides custom panels."""
from part.models import Part from part.models import Part
@ -45,7 +98,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
_part.active = True _part.active = True
_part.save() _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} query_data = {'target_model': 'part', 'target_id': _part.pk}
@ -59,7 +112,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data) response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default # 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.active = False
_part.save() _part.save()
@ -74,23 +127,27 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
response = self.get(url, data=query_data) response = self.get(url, data=query_data)
# There should still be 3 panels # There should still be 2 panels
self.assertEqual(3, len(response.data)) self.assertEqual(2, len(response.data))
# Check for the correct panel names for panel in response.data:
self.assertEqual(response.data[0]['name'], 'sample_panel') self.assertEqual(panel['plugin_name'], 'sampleui')
self.assertIn('content', response.data[0]) self.assertEqual(panel['feature_type'], 'panel')
self.assertNotIn('source', response.data[0])
self.assertEqual(response.data[1]['name'], 'broken_panel') self.assertEqual(response.data[0]['key'], 'broken-panel')
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js') self.assertEqual(response.data[0]['title'], 'Broken Panel')
self.assertNotIn('content', response.data[1]) 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( 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 # Next, disable the global setting for UI integration
InvenTreeSetting.set_setting( InvenTreeSetting.set_setting(
@ -105,8 +162,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
# Set the setting back to True for subsequent tests # Set the setting back to True for subsequent tests
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None) InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
def test_ui_features(self): def test_ui_template_editors(self):
"""Test that the sample UI plugin provides custom features.""" """Test that the sample UI plugin provides template editor features."""
template_editor_url = reverse( template_editor_url = reverse(
'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'} 'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'}
) )
@ -120,30 +177,39 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
'template_model': 'part', 'template_model': 'part',
} }
# Request custom template editor information # Request custom label template editor information
response = self.get(template_editor_url, data=query_data_label) response = self.get(template_editor_url, data=query_data_label)
self.assertEqual(1, len(response.data)) 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) response = self.get(template_editor_url, data=query_data_report)
self.assertEqual(0, len(response.data)) self.assertEqual(0, len(response.data))
# Request custom report template preview information
response = self.get(template_preview_url, data=query_data_report) response = self.get(template_preview_url, data=query_data_report)
self.assertEqual(1, len(response.data)) self.assertEqual(1, len(response.data))
# Check for the correct feature details here data = response.data[0]
self.assertEqual(response.data[0]['feature_type'], 'template_preview')
self.assertDictEqual( for k, v in {
response.data[0]['options'], 'plugin_name': 'sampleui',
{ 'feature_type': 'template_preview',
'key': 'sample-template-preview', 'key': 'sample-template-preview',
'title': 'Sample Template Preview', 'title': 'Sample Template Preview',
'icon': 'category', 'context': None,
}, 'source': '/static/plugins/sampleui/sample_preview.js:getTemplatePreview',
) }.items():
self.assertEqual( self.assertEqual(data[k], v)
response.data[0]['source'],
'/static/plugin/sample_template.js:getTemplatePreview',
)
# Next, disable the global setting for UI integration # Next, disable the global setting for UI integration
InvenTreeSetting.set_setting( InvenTreeSetting.set_setting(

View File

@ -1,30 +0,0 @@
{% load i18n %}
<h4>Custom Plugin Panel</h4>
<p>
This content has been rendered by a custom plugin, and will be displayed for any "part" instance
(as long as the plugin is enabled).
This content has been rendered on the server, using the django templating system.
</p>
<h5>Part Details</h5>
<table class='table table-striped table-condensed'>
<tr>
<th>Part Name</th>
<td>{{ part.name }}</td>
</tr>
<tr>
<th>Part Description</th>
<td>{{ part.description }}</td>
</tr>
<tr>
<th>Part Category</th>
<td>{{ part.category.pathstring }}</td>
</tr>
<tr>
<th>Part IPN</th>
<td>{% if part.IPN %}{{ part.IPN }}{% else %}<i>No IPN specified</i>{% endif %}</td>
</tr>
</table>

View File

@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
from InvenTree.version import INVENTREE_SW_VERSION from InvenTree.version import INVENTREE_SW_VERSION
from part.models import Part from part.models import Part
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.helpers import render_template, render_text
from plugin.mixins import SettingsMixin, UserInterfaceMixin from plugin.mixins import SettingsMixin, UserInterfaceMixin
@ -19,7 +18,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
SLUG = 'sampleui' SLUG = 'sampleui'
TITLE = 'Sample User Interface Plugin' TITLE = 'Sample User Interface Plugin'
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations' DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
VERSION = '1.1' VERSION = '2.0'
ADMIN_SOURCE = 'ui_settings.js' 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.""" """Return a list of custom panels to be injected into the UI."""
panels = [] panels = []
context = context or {}
# First, add a custom panel which will appear on every type of page # First, add a custom panel which will appear on every type of page
# This panel will contain a simple message # This panel will contain a simple message
content = render_text( target_model = context.get('target_model', None)
""" target_id = context.get('target_id', None)
This is a <i>sample panel</i> which appears on every page.
It renders a simple string of <b>HTML</b> content.
<br>
<h5>Instance Details:</h5>
<ul>
<li>Instance Type: {{ instance_type }}</li>
<li>Instance ID: {{ instance_id }}</li>
</ul>
""",
context={'instance_type': instance_type, 'instance_id': instance_id},
)
panels.append({
'name': 'sample_panel',
'label': 'Sample Panel',
'content': content,
})
# A broken panel which tries to load a non-existent JS file # 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({ panels.append({
'name': 'broken_panel', 'key': 'broken-panel',
'label': 'Broken Panel', 'title': 'Broken Panel',
'source': '/this/does/not/exist.js', '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 # Note that we additionally provide some "context" data to the front-end render function
if self.get_setting('ENABLE_DYNAMIC_PANEL'): if self.get_setting('ENABLE_DYNAMIC_PANEL'):
panels.append({ panels.append({
'name': 'dynamic_panel', 'key': 'dynamic-panel',
'label': 'Dynamic Part Panel', 'title': 'Dynamic Panel',
'source': self.plugin_static_file('sample_panel.js'), 'source': self.plugin_static_file('sample_panel.js'),
'icon': 'part',
'context': { 'context': {
'version': INVENTREE_SW_VERSION, 'version': INVENTREE_SW_VERSION,
'plugin_version': self.VERSION, 'plugin_version': self.VERSION,
'random': random.randint(1, 100), 'random': random.randint(1, 100),
'time': time.time(), 'time': time.time(),
}, },
'icon': 'part',
}) })
# Next, add a custom panel which will appear on the 'part' page # Next, add a custom panel which will appear on the 'part' page
# Note that this content is rendered from a template file, # Note that this content is rendered from a template file,
# using the django templating system # 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: try:
part = Part.objects.get(pk=instance_id) part = Part.objects.get(pk=target_id)
except (Part.DoesNotExist, ValueError): except (Part.DoesNotExist, ValueError):
part = None 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({ panels.append({
'name': 'part_panel', 'key': 'part-panel',
'label': 'Part Panel', 'title': _('Part Panel'),
'content': content, '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 # Next, add a custom panel which will appear on the 'purchaseorder' page
if ( if target_model == 'purchaseorder' and self.get_setting(
self.get_setting('ENABLE_PURCHASE_ORDER_PANELS') 'ENABLE_PURCHASE_ORDER_PANELS'
and instance_type == 'purchaseorder'
): ):
panels.append({ panels.append({
'name': 'purchase_order_panel', 'key': 'purchase_order_panel',
'label': 'Purchase Order Panel', 'title': 'Purchase Order Panel',
'content': 'This is a custom panel which appears on the <b>Purchase Order</b> view page.', '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 return panels
def get_ui_features(self, feature_type, context, request): def get_ui_dashboard_items(self, request, context, **kwargs):
"""Return a list of custom features to be injected into the UI.""" """Return a list of custom dashboard items."""
if ( items = [
feature_type == 'template_editor' {
and context.get('template_type') == 'labeltemplate' '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},
},
]
# 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 [ return [
{ {
'feature_type': 'template_editor',
'options': {
'key': 'sample-template-editor', 'key': 'sample-template-editor',
'title': 'Sample Template Editor', 'title': 'Sample Template Editor',
'icon': 'keywords', 'icon': 'keywords',
}, 'source': self.plugin_static_file(
'source': '/static/plugin/sample_template.js:getTemplateEditor', 'sample_template.js:getTemplateEditor'
} ),
]
if feature_type == 'template_preview':
return [
{
'feature_type': 'template_preview',
'options': {
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'icon': 'category',
},
'source': '/static/plugin/sample_template.js:getTemplatePreview',
} }
] ]
return [] 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: def get_admin_context(self) -> dict:
"""Return custom context data which can be rendered in the admin panel.""" """Return custom context data which can be rendered in the admin panel."""
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'} return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@ import { ModelType } from '../../../enums/ModelType';
import { TablerIconType } from '../../../functions/icons'; import { TablerIconType } from '../../../functions/icons';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl } from '../../../states/ApiState';
import { TemplateI } from '../../../tables/settings/TemplateTable'; import { TemplateI } from '../../../tables/settings/TemplateTable';
import { Boundary } from '../../Boundary';
import { SplitButton } from '../../buttons/SplitButton'; import { SplitButton } from '../../buttons/SplitButton';
import { StandaloneField } from '../../forms/StandaloneField'; import { StandaloneField } from '../../forms/StandaloneField';
import { ModelInformationDict } from '../../render/ModelType'; import { ModelInformationDict } from '../../render/ModelType';
@ -41,21 +42,25 @@ import { ModelInformationDict } from '../../render/ModelType';
type EditorProps = { type EditorProps = {
template: TemplateI; template: TemplateI;
}; };
type EditorRef = { type EditorRef = {
setCode: (code: string) => void | Promise<void>; setCode: (code: string) => void | Promise<void>;
getCode: () => (string | undefined) | Promise<string | undefined>; getCode: () => (string | undefined) | Promise<string | undefined>;
}; };
export type EditorComponent = React.ForwardRefExoticComponent< export type EditorComponent = React.ForwardRefExoticComponent<
EditorProps & React.RefAttributes<EditorRef> EditorProps & React.RefAttributes<EditorRef>
>; >;
export type Editor = { export type Editor = {
key: string; key: string;
name: string; name: string;
icon: TablerIconType; icon?: TablerIconType;
component: EditorComponent; component: EditorComponent;
}; };
type PreviewAreaProps = {}; type PreviewAreaProps = {};
export type PreviewAreaRef = { export type PreviewAreaRef = {
updatePreview: ( updatePreview: (
code: string, code: string,
@ -64,9 +69,11 @@ export type PreviewAreaRef = {
templateEditorProps: TemplateEditorProps templateEditorProps: TemplateEditorProps
) => void | Promise<void>; ) => void | Promise<void>;
}; };
export type PreviewAreaComponent = React.ForwardRefExoticComponent< export type PreviewAreaComponent = React.ForwardRefExoticComponent<
PreviewAreaProps & React.RefAttributes<PreviewAreaRef> PreviewAreaProps & React.RefAttributes<PreviewAreaRef>
>; >;
export type PreviewArea = { export type PreviewArea = {
key: string; key: string;
name: string; name: string;
@ -247,6 +254,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
}, [previewApiUrl, templateFilters]); }, [previewApiUrl, templateFilters]);
return ( return (
<Boundary label="TemplateEditor">
<Stack style={{ height: '100%', flex: '1' }}> <Stack style={{ height: '100%', flex: '1' }}>
<Split style={{ gap: '10px' }}> <Split style={{ gap: '10px' }}>
<Tabs <Tabs
@ -264,15 +272,17 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
}} }}
> >
<Tabs.List> <Tabs.List>
{editors.map((Editor) => ( {editors.map((Editor, index) => {
return (
<Tabs.Tab <Tabs.Tab
key={Editor.key} key={Editor.key}
value={Editor.key} value={Editor.key}
leftSection={<Editor.icon size="0.8rem" />} leftSection={Editor.icon && <Editor.icon size="0.8rem" />}
> >
{Editor.name} {Editor.name}
</Tabs.Tab> </Tabs.Tab>
))} );
})}
<Group justify="right" style={{ flex: '1' }} wrap="nowrap"> <Group justify="right" style={{ flex: '1' }} wrap="nowrap">
<SplitButton <SplitButton
@ -331,7 +341,9 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
<Tabs.Tab <Tabs.Tab
key={PreviewArea.key} key={PreviewArea.key}
value={PreviewArea.key} value={PreviewArea.key}
leftSection={<PreviewArea.icon size="0.8rem" />} leftSection={
PreviewArea.icon && <PreviewArea.icon size="0.8rem" />
}
> >
{PreviewArea.name} {PreviewArea.name}
</Tabs.Tab> </Tabs.Tab>
@ -407,5 +419,6 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
</Tabs> </Tabs>
</Split> </Split>
</Stack> </Stack>
</Boundary>
); );
} }

View File

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

View File

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

View File

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

View File

@ -1,28 +1,11 @@
import { Anchor, Container, Group } from '@mantine/core';
import { footerLinks } from '../../defaults/links';
import * as classes from '../../main.css'; import * as classes from '../../main.css';
import { InvenTreeLogoHomeButton } from '../items/InvenTreeLogo';
export function Footer() { 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 ( return (
<div className={classes.layoutFooter}> <div className={classes.layoutFooter}>
<Container className={classes.layoutFooterInner} size={'100%'}> {
<InvenTreeLogoHomeButton /> // Placeholder for footer links
<Group className={classes.layoutFooterLinks}>{items}</Group> }
</Container>
</div> </div>
); );
} }

View File

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

View File

@ -1,117 +1,15 @@
import { Trans, t } from '@lingui/macro'; import { UnstyledButton } from '@mantine/core';
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 { 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 { InvenTreeLogo } from '../items/InvenTreeLogo';
import { MenuLinks } from '../items/MenuLinks';
const onlyItems = Object.values(menuItems);
export function NavHoverMenu({ export function NavHoverMenu({
openDrawer: openDrawer openDrawer: openDrawer
}: Readonly<{ }: Readonly<{
openDrawer: () => void; 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 ( return (
<HoverCard <UnstyledButton onClick={() => openDrawer()} aria-label="navigation-menu">
width={600}
openDelay={300}
position="bottom"
shadow="md"
withinPortal
>
<HoverCard.Target>
<UnstyledButton onClick={() => openDrawer()} aria-label="Homenav">
<InvenTreeLogo /> <InvenTreeLogo />
</UnstyledButton> </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>
); );
} }

View File

@ -3,22 +3,26 @@ import {
Container, Container,
Drawer, Drawer,
Flex, Flex,
Group,
ScrollArea, ScrollArea,
Space, Space
Title
} from '@mantine/core'; } from '@mantine/core';
import { useViewportSize } from '@mantine/hooks'; 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 { AboutLinks, DocumentationLinks } from '../../defaults/links';
import { menuItems } from '../../defaults/menuItems'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import useInstanceName from '../../hooks/UseInstanceName';
import * as classes from '../../main.css'; 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 { MenuLinkItem, MenuLinks } from '../items/MenuLinks';
import { StylishText } from '../items/StylishText';
// TODO @matmair #1: implement plugin loading and menu item generation see #5269 // TODO @matmair #1: implement plugin loading and menu item generation see #5269
const plugins: MenuLinkItem[] = []; const plugins: MenuLinkItem[] = [];
const onlyItems = Object.values(menuItems);
export function NavigationDrawer({ export function NavigationDrawer({
opened, opened,
@ -31,39 +35,163 @@ export function NavigationDrawer({
<Drawer <Drawer
opened={opened} opened={opened}
onClose={close} onClose={close}
overlayProps={{ opacity: 0.5, blur: 4 }} size="lg"
withCloseButton={false} withCloseButton={false}
classNames={{ classNames={{
body: classes.navigationDrawer body: classes.navigationDrawer
}} }}
> >
<DrawerContent /> <DrawerContent closeFunc={close} />
</Drawer> </Drawer>
); );
} }
function DrawerContent() {
function DrawerContent({ closeFunc }: { closeFunc?: () => void }) {
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const [scrollHeight, setScrollHeight] = useState(0); const [scrollHeight, setScrollHeight] = useState(0);
const ref = useRef(null); const ref = useRef(null);
const { height } = useViewportSize(); const { height } = useViewportSize();
const title = useInstanceName();
// update scroll height when viewport size changes // update scroll height when viewport size changes
useEffect(() => { useEffect(() => {
if (ref.current == null) return; if (ref.current == null) return;
setScrollHeight(height - ref.current['clientHeight'] - 65); 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 ( return (
<Flex direction="column" mih="100vh" p={16}> <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}> <Container className={classes.layoutContent} p={0}>
<ScrollArea h={scrollHeight} type="always" offsetScrollbars> <ScrollArea h={scrollHeight} type="always" offsetScrollbars>
<Title order={5}>{t`Pages`}</Title> <MenuLinks
<MenuLinks links={onlyItems} /> 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" /> <Space h="md" />
{plugins.length > 0 ? ( {plugins.length > 0 ? (
<> <>
<Title order={5}>{t`Plugins`}</Title> <MenuLinks
<MenuLinks links={plugins} /> title={t`Plugins`}
links={plugins}
beforeClick={closeFunc}
/>
</> </>
) : ( ) : (
<></> <></>
@ -72,11 +200,17 @@ function DrawerContent() {
</Container> </Container>
<div ref={ref}> <div ref={ref}>
<Space h="md" /> <Space h="md" />
<Title order={5}>{t`Documentation`}</Title> <MenuLinks
<DocumentationLinks links={navDocLinks} /> title={t`Documentation`}
links={menuItemsDocumentation}
beforeClick={closeFunc}
/>
<Space h="md" /> <Space h="md" />
<Title order={5}>{t`About`}</Title> <MenuLinks
<DocumentationLinks links={aboutLinks} /> title={t`About`}
links={menuItemsAbout}
beforeClick={closeFunc}
/>
</div> </div>
</Flex> </Flex>
); );

View File

@ -28,6 +28,7 @@ import { UserStateProps, useUserState } from '../../states/UserState';
* @param navigate - The navigation function (see react-router-dom) * @param navigate - The navigation function (see react-router-dom)
* @param theme - The current Mantine theme * @param theme - The current Mantine theme
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark') * @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 = { export type InvenTreeContext = {
api: AxiosInstance; api: AxiosInstance;
@ -38,6 +39,7 @@ export type InvenTreeContext = {
navigate: NavigateFunction; navigate: NavigateFunction;
theme: MantineTheme; theme: MantineTheme;
colorScheme: MantineColorScheme; colorScheme: MantineColorScheme;
context?: any;
}; };
export const useInvenTreeContext = () => { export const useInvenTreeContext = () => {

View File

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

View File

@ -1,53 +1,9 @@
import { t } from '@lingui/macro'; import { Stack } from '@mantine/core';
import { Alert, Stack, Text } from '@mantine/core'; import { ReactNode } from 'react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { InvenTreeContext } from './PluginContext'; import { InvenTreeContext } from './PluginContext';
import { findExternalPluginFunction } from './PluginSource'; import { PluginUIFeature } from './PluginUIFeature';
import RemoteComponent from './RemoteComponent';
// 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;
}
}
/** /**
* A custom panel which can be used to display plugin content. * 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 * - `params` is the set of run-time parameters to pass to the content rendering function
*/ */
export default function PluginPanelContent({ export default function PluginPanelContent({
pluginProps, pluginFeature,
pluginContext pluginContext
}: Readonly<{ }: Readonly<{
pluginProps: PluginPanelProps; pluginFeature: PluginUIFeature;
pluginContext: InvenTreeContext; pluginContext: InvenTreeContext;
}>): ReactNode { }>): 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 ( return (
<Stack gap="xs"> <Stack gap="xs">
{error && ( <RemoteComponent
<Alert source={pluginFeature.source}
color="red" defaultFunctionName="renderPanel"
title={t`Error Loading Plugin`} context={pluginContext}
icon={<IconExclamationCircle />} />
>
<Text>{error}</Text>
</Alert>
)}
<div ref={ref as any}></div>
</Stack> </Stack>
); );
} }

View File

@ -1,10 +1,5 @@
import { t } from '@lingui/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useInvenTreeContext } from './PluginContext'; import { useInvenTreeContext } from './PluginContext';
import { findExternalPluginFunction } from './PluginSource'; import RemoteComponent from './RemoteComponent';
/** /**
* Interface for the plugin admin data * Interface for the plugin admin data
@ -22,65 +17,17 @@ export interface PluginAdminInterface {
* which exports a function `renderPluginSettings` * which exports a function `renderPluginSettings`
*/ */
export default function PluginSettingsPanel({ export default function PluginSettingsPanel({
pluginInstance,
pluginAdmin pluginAdmin
}: { }: {
pluginInstance: any;
pluginAdmin: PluginAdminInterface; pluginAdmin: PluginAdminInterface;
}) { }) {
const ref = useRef<HTMLDivElement>();
const [error, setError] = useState<string | undefined>(undefined);
const pluginContext = useInvenTreeContext(); 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 ( return (
<> <RemoteComponent
<Stack gap="xs"> source={pluginAdmin.source}
{error && ( defaultFunctionName="renderPluginSettings"
<Alert context={{ ...pluginContext, context: pluginAdmin.context }}
color="red" />
title={t`Error Loading Plugin`}
icon={<IconExclamationCircle />}
>
<Text>{error}</Text>
</Alert>
)}
<div ref={ref as any}></div>
</Stack>
</>
); );
} }

View File

@ -21,6 +21,42 @@ import {
TemplatePreviewUIFeature TemplatePreviewUIFeature
} from './PluginUIFeatureTypes'; } 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 = ( export const getPluginTemplateEditor = (
func: PluginUIFuncWithoutInvenTreeContextType<TemplateEditorUIFeature>, func: PluginUIFuncWithoutInvenTreeContextType<TemplateEditorUIFeature>,
template: TemplateI template: TemplateI

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -82,21 +82,6 @@ export const link = style({
} }
}); });
export const subLink = style({
width: '100%',
padding: `${vars.spacing.xs} ${vars.spacing.md}`,
borderRadius: vars.radiusDefault,
':hover': {
[vars.lightSelector]: { backgroundColor: vars.colors.gray[0] },
[vars.darkSelector]: { backgroundColor: vars.colors.dark[7] }
},
':active': {
color: vars.colors.defaultHover
}
});
export const docHover = style({ export const docHover = style({
border: `1px dashed ` border: `1px dashed `
}); });
@ -106,24 +91,6 @@ export const layoutContent = style({
width: '100%' 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({ export const tabs = style({
[vars.smallerThan('sm')]: { [vars.smallerThan('sm')]: {
display: 'none' display: 'none'

View File

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

View File

@ -1,63 +1,9 @@
import { Trans } from '@lingui/macro'; import DashboardLayout from '../../components/dashboard/DashboardLayout';
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
}
];
export default function Home() { export default function Home() {
const [username] = useUserState((state) => [state.username()]);
return ( return (
<> <>
<Title order={1}> <DashboardLayout />
<Trans>Welcome to your Dashboard{username && `, ${username}`}</Trans>
</Title>
<WidgetLayout items={vals} />
</> </>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -135,6 +135,14 @@ export default function InvenTreeTableHeader({
/> />
</Boundary> </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="apart" grow wrap="nowrap">
<Group justify="left" key="custom-actions" gap={5} wrap="nowrap"> <Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
@ -193,21 +201,6 @@ export default function InvenTreeTableHeader({
onToggleColumn={toggleColumn} 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 && ( {tableProps.enableFilters && filters.length > 0 && (
<Indicator <Indicator
size="xs" size="xs"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,76 @@ import { test } from '../baseFixtures';
import { baseUrl } from '../defaults'; import { baseUrl } from '../defaults';
import { doQuickLogin } from '../login'; 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); await doQuickLogin(page);
// Navigate to a known assembly which is *not* locked // 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(); await page.getByText('Part parameters cannot be').waitFor();
}); });
test('Pages - Part - Allocations', async ({ page }) => { test('Parts - Allocations', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
// Let's look at the allocations for a single stock item // 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(); 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); await doQuickLogin(page);
// Part with no history // Part with no history
@ -106,7 +175,7 @@ test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
await page.waitForURL('**/part/98/**'); await page.waitForURL('**/part/98/**');
}); });
test('Pages - Part - Pricing (Supplier)', async ({ page }) => { test('Parts - Pricing (Supplier)', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
// Part // Part
@ -132,7 +201,7 @@ test('Pages - Part - Pricing (Supplier)', async ({ page }) => {
// await page.waitForURL('**/purchasing/supplier-part/697/'); // await page.waitForURL('**/purchasing/supplier-part/697/');
}); });
test('Pages - Part - Pricing (Variant)', async ({ page }) => { test('Parts - Pricing (Variant)', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
// Part // Part
@ -158,7 +227,7 @@ test('Pages - Part - Pricing (Variant)', async ({ page }) => {
await page.waitForURL('**/part/109/**'); await page.waitForURL('**/part/109/**');
}); });
test('Pages - Part - Pricing (Internal)', async ({ page }) => { test('Parts - Pricing (Internal)', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
// Part // Part
@ -183,7 +252,7 @@ test('Pages - Part - Pricing (Internal)', async ({ page }) => {
await page.getByText('Part *M2x4 SHCSSocket head').click(); await page.getByText('Part *M2x4 SHCSSocket head').click();
}); });
test('Pages - Part - Pricing (Purchase)', async ({ page }) => { test('Parts - Pricing (Purchase)', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
// Part // Part
@ -205,7 +274,7 @@ test('Pages - Part - Pricing (Purchase)', async ({ page }) => {
await page.getByText('2022-04-29').waitFor(); await page.getByText('2022-04-29').waitFor();
}); });
test('Pages - Part - Attachments', async ({ page }) => { test('Parts - Attachments', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/attachments`); await page.goto(`${baseUrl}/part/69/attachments`);
@ -227,7 +296,7 @@ test('Pages - Part - Attachments', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
}); });
test('Pages - Part - Parameters', async ({ page }) => { test('Parts - Parameters', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/parameters`); await page.goto(`${baseUrl}/part/69/parameters`);
@ -254,7 +323,7 @@ test('Pages - Part - Parameters', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
}); });
test('Pages - Part - Notes', async ({ page }) => { test('Parts - Notes', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.goto(`${baseUrl}/part/69/notes`); await page.goto(`${baseUrl}/part/69/notes`);
@ -276,7 +345,7 @@ test('Pages - Part - Notes', async ({ page }) => {
await page.getByLabel('Close Editor').waitFor(); await page.getByLabel('Close Editor').waitFor();
}); });
test('Pages - Part - 404', async ({ page }) => { test('Parts - 404', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.goto(`${baseUrl}/part/99999/`); await page.goto(`${baseUrl}/part/99999/`);
@ -286,7 +355,7 @@ test('Pages - Part - 404', async ({ page }) => {
await page.evaluate(() => console.clear()); await page.evaluate(() => console.clear());
}); });
test('Pages - Part - Revision', async ({ page }) => { test('Parts - Revision', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
await page.goto(`${baseUrl}/part/906/details`); await page.goto(`${baseUrl}/part/906/details`);

View File

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

View File

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

View File

@ -2,72 +2,6 @@ import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js'; import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.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 }) => { test('Sales', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
@ -122,13 +56,13 @@ test('Sales', async ({ page }) => {
test('Scanning', async ({ page }) => { test('Scanning', async ({ page }) => {
await doQuickLogin(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.getByRole('button', { name: 'System Information' }).click();
await page.locator('button').filter({ hasText: 'Dismiss' }).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.getByPlaceholder('Select input method').click();
await page.getByRole('option', { name: 'Manual input' }).click(); await page.getByRole('option', { name: 'Manual input' }).click();
await page.getByPlaceholder('Enter item serial or data').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(); 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 }) => { test('Company', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);

View File

@ -14,6 +14,8 @@ test('Plugins - Panels', async ({ page, request }) => {
value: true value: true
}); });
await page.waitForTimeout(500);
// Ensure that the SampleUI plugin is enabled // Ensure that the SampleUI plugin is enabled
await setPluginState({ await setPluginState({
request, request,
@ -21,28 +23,34 @@ test('Plugins - Panels', async ({ page, request }) => {
state: true state: true
}); });
await page.waitForTimeout(500);
// Navigate to the "part" page // Navigate to the "part" page
await page.goto(`${baseUrl}/part/69/`); await page.goto(`${baseUrl}/part/69/`);
// Ensure basic part tab is available // Ensure basic part tab is available
await page.getByRole('tab', { name: 'Part Details' }).waitFor(); 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 // 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.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 await page
.getByText('This panel has been dynamically rendered by the plugin system') .getByText('This panel has been dynamically rendered by the plugin system')
.waitFor(); .waitFor();
await page.getByText('Instance ID: 69');
await page.getByRole('tab', { name: 'Part Panel', exact: true }).click(); 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'); await page.getByText('This content has been rendered by a custom plugin');
// Disable the plugin, and ensure it is no longer visible // Disable the plugin, and ensure it is no longer visible

View File

@ -3,7 +3,45 @@ import { apiUrl, baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js'; import { doQuickLogin } from './login.js';
import { setSettingState } from './settings.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 // Note here we login with admin access
await doQuickLogin(page, 'admin', 'inventree'); await doQuickLogin(page, 'admin', 'inventree');
@ -86,7 +124,7 @@ test('Admin', async ({ page }) => {
await page.getByRole('button', { name: 'Submit' }).click(); 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 // Login with admin credentials
await doQuickLogin(page, 'admin', 'inventree'); 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 // Try to access "admin" page with a non-staff user
await doQuickLogin(page, 'allaccess', 'nolimits'); await doQuickLogin(page, 'allaccess', 'nolimits');