Skip to content

Commit

Permalink
[PUI] Plugin settings UI (#8228)
Browse files Browse the repository at this point in the history
* Visual tweaks for admin pages

* Provide admin js file via API

* Backend fixes

* Tweak error detail drawer

* Refactor plugin detail panel

- Split out into separate files
- Use <Accordion />
- Display custom configuration (if available)

* Refactoring

* Add custom configuration to sample UI plugin

* Bump API version

* Add separate API endpoint for admin integration details

* Refactor plugin drawer

* Null check

* Add playwright tests for custom admin integration

* Enable plugin panels in "settings" pages

* Fix for unit test

* Hide "Plugin Settings" for plugin without "settings" mixin

* Fixes for playwright tests

* Update playwright tests

* Improved error message
  • Loading branch information
SchrodingersGat authored Oct 7, 2024
1 parent 36e3159 commit 798e25a
Show file tree
Hide file tree
Showing 26 changed files with 540 additions and 242 deletions.
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 264
INVENTREE_API_VERSION = 265

"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""


INVENTREE_API_TEXT = """
265 - 2024-10-06 : https://github.com/inventree/InvenTree/pull/8228
- Adds API endpoint for providing custom admin integration details for plugins
264 - 2024-10-03 : https://github.com/inventree/InvenTree/pull/8231
- Adds Sales Order Shipment attachment model type
Expand Down
16 changes: 16 additions & 0 deletions src/backend/InvenTree/plugin/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from InvenTree.mixins import (
CreateAPI,
ListAPI,
RetrieveAPI,
RetrieveDestroyAPI,
RetrieveUpdateAPI,
UpdateAPI,
Expand Down Expand Up @@ -177,6 +178,18 @@ def delete(self, request, *args, **kwargs):
return super().delete(request, *args, **kwargs)


class PluginAdminDetail(RetrieveAPI):
"""Endpoint for viewing admin integration plugin details.
This endpoint is used to view the available admin integration options for a plugin.
"""

queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginAdminDetailSerializer
lookup_field = 'key'
lookup_url_kwarg = 'plugin'


class PluginInstall(CreateAPI):
"""Endpoint for installing a new plugin."""

Expand Down Expand Up @@ -484,6 +497,9 @@ class PluginMetadataView(MetadataView):
PluginUninstall.as_view(),
name='api-plugin-uninstall',
),
path(
'admin/', PluginAdminDetail.as_view(), name='api-plugin-admin'
),
path('', PluginDetail.as_view(), name='api-plugin-detail'),
]),
),
Expand Down
4 changes: 0 additions & 4 deletions src/backend/InvenTree/plugin/base/ui/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ class UserInterfaceMixin:
- All content is accessed via the API, as requested by the user interface.
- This means that content can be dynamically generated, based on the current state of the system.
The following custom UI methods are available:
- get_ui_panels: Return a list of custom panels to be injected into the UI
"""

class MixinMeta:
Expand Down
4 changes: 3 additions & 1 deletion src/backend/InvenTree/plugin/base/ui/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ def test_panels(self):
self.assertNotIn('content', response.data[1])

self.assertEqual(response.data[2]['name'], 'dynamic_panel')
self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js')
self.assertEqual(
response.data[2]['source'], '/static/plugins/sampleui/sample_panel.js'
)
self.assertNotIn('content', response.data[2])

# Next, disable the global setting for UI integration
Expand Down
37 changes: 37 additions & 0 deletions src/backend/InvenTree/plugin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,43 @@ def is_package(self) -> bool:

return getattr(self.plugin, 'is_package', False)

@property
def admin_source(self) -> str:
"""Return the path to the javascript file which renders custom admin content for this plugin.
- It is required that the file provides a 'renderPluginSettings' function!
"""
if not self.plugin:
return None

if not self.is_installed() or not self.active:
return None

if hasattr(self.plugin, 'get_admin_source'):
try:
return self.plugin.get_admin_source()
except Exception:
pass

return None

@property
def admin_context(self) -> dict:
"""Return the context data for the admin integration."""
if not self.plugin:
return None

if not self.is_installed() or not self.active:
return None

if hasattr(self.plugin, 'get_admin_context'):
try:
return self.plugin.get_admin_context()
except Exception:
pass

return {}

def activate(self, active: bool) -> None:
"""Set the 'active' status of this plugin instance."""
from InvenTree.tasks import check_for_migrations, offload_task
Expand Down
28 changes: 27 additions & 1 deletion src/backend/InvenTree/plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
WEBSITE = None
LICENSE = None

# Optional path to a JavaScript file which will be loaded in the admin panel
# This file must provide a function called renderPluginSettings
ADMIN_SOURCE = None

def __init__(self):
"""Init a plugin.
Expand Down Expand Up @@ -445,4 +449,26 @@ def plugin_static_file(self, *args):

from django.conf import settings

return '/' + os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
url = os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)

if not url.startswith('/'):
url = '/' + url

return url

def get_admin_source(self) -> str:
"""Return a path to a JavaScript file which contains custom UI settings.
The frontend code expects that this file provides a function named 'renderPluginSettings'.
"""
if not self.ADMIN_SOURCE:
return None

return self.plugin_static_file(self.ADMIN_SOURCE)

def get_admin_context(self) -> dict:
"""Return a context dictionary for the admin panel settings.
This is an optional method which can be overridden by the plugin.
"""
return None
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
VERSION = '1.1'

ADMIN_SOURCE = 'ui_settings.js'

SETTINGS = {
'ENABLE_PART_PANELS': {
'name': _('Enable Part Panels'),
Expand Down Expand Up @@ -77,7 +79,7 @@ def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs)
})

# A broken panel which tries to load a non-existent JS file
if self.get_setting('ENABLE_BROKEN_PANElS'):
if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
panels.append({
'name': 'broken_panel',
'label': 'Broken Panel',
Expand All @@ -90,7 +92,7 @@ def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs)
panels.append({
'name': 'dynamic_panel',
'label': 'Dynamic Part Panel',
'source': '/static/plugin/sample_panel.js',
'source': self.plugin_static_file('sample_panel.js'),
'context': {
'version': INVENTREE_SW_VERSION,
'plugin_version': self.VERSION,
Expand Down Expand Up @@ -166,3 +168,7 @@ def get_ui_features(self, feature_type, context, request):
]

return []

def get_admin_context(self) -> dict:
"""Return custom context data which can be rendered in the admin panel."""
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@


export function renderPluginSettings(target, data) {

console.log("renderPluginSettings:", data);

target.innerHTML = `
<h4>Custom Plugin Configuration Content</h4>
<p>Custom plugin configuration UI elements can be rendered here.</p>
<p>The following context data was provided by the server:</p>
<ul>
${Object.entries(data.context).map(([key, value]) => `<li>${key}: ${value}</li>`).join('')}
</ul>
`;
}
25 changes: 25 additions & 0 deletions src/backend/InvenTree/plugin/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,31 @@ class Meta:
mixins = serializers.DictField(read_only=True)


class PluginAdminDetailSerializer(serializers.ModelSerializer):
"""Serializer for a PluginConfig with admin details."""

class Meta:
"""Metaclass options for serializer."""

model = PluginConfig

fields = ['source', 'context']

source = serializers.CharField(
allow_null=True,
label=_('Source File'),
help_text=_('Path to the source file for admin integration'),
source='admin_source',
)

context = serializers.JSONField(
allow_null=True,
label=_('Context'),
help_text=_('Optional context data for the admin integration'),
source='admin_context',
)


class PluginConfigInstallSerializer(serializers.Serializer):
"""Serializer for installing a new plugin."""

Expand Down
65 changes: 40 additions & 25 deletions src/frontend/src/components/nav/SettingsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,58 @@
import { Anchor, Group, Stack, Text, Title } from '@mantine/core';
import { t } from '@lingui/macro';
import {
Anchor,
Group,
SegmentedControl,
Stack,
Text,
Title
} from '@mantine/core';
import { IconSwitch } from '@tabler/icons-react';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';

import { useUserState } from '../../states/UserState';
import { StylishText } from '../items/StylishText';

interface SettingsHeaderInterface {
title: string | ReactNode;
label: string;
title: string;
shorthand?: string;
subtitle?: string | ReactNode;
switch_condition?: boolean;
switch_text?: string | ReactNode;
switch_link?: string;
}

/**
* Construct a settings page header with interlinks to one other settings page
*/
export function SettingsHeader({
label,
title,
shorthand,
subtitle,
switch_condition = true,
switch_text,
switch_link
subtitle
}: Readonly<SettingsHeaderInterface>) {
const user = useUserState();
const navigate = useNavigate();

return (
<Stack gap="0" ml={'sm'}>
<Group>
<Title order={3}>{title}</Title>
{shorthand && <Text c="dimmed">({shorthand})</Text>}
</Group>
<Group>
{subtitle ? <Text c="dimmed">{subtitle}</Text> : null}
{switch_text && switch_link && switch_condition && (
<Anchor component={Link} to={switch_link}>
<IconSwitch size={14} />
{switch_text}
</Anchor>
)}
</Group>
</Stack>
<Group justify="space-between">
<Stack gap="0" ml={'sm'}>
<Group>
<StylishText size="xl">{title}</StylishText>
{shorthand && <Text c="dimmed">({shorthand})</Text>}
</Group>
<Group>{subtitle ? <Text c="dimmed">{subtitle}</Text> : null}</Group>
</Stack>
{user.isStaff() && (
<SegmentedControl
data={[
{ value: 'user', label: t`User Settings` },
{ value: 'system', label: t`System Settings` },
{ value: 'admin', label: t`Admin Center` }
]}
onChange={(value) => navigate(`/settings/${value}`)}
value={label}
/>
)}
</Group>
);
}
Loading

0 comments on commit 798e25a

Please sign in to comment.