Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PUI] Plugin settings UI #8228

Merged
merged 25 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
67f30c7
Visual tweaks for admin pages
SchrodingersGat Oct 1, 2024
8e51085
Provide admin js file via API
SchrodingersGat Oct 1, 2024
c1382df
Backend fixes
SchrodingersGat Oct 1, 2024
38cebc4
Tweak error detail drawer
SchrodingersGat Oct 1, 2024
c2d7601
Refactor plugin detail panel
SchrodingersGat Oct 1, 2024
c5f7a7d
Refactoring
SchrodingersGat Oct 1, 2024
12319da
Add custom configuration to sample UI plugin
SchrodingersGat Oct 1, 2024
aa53d83
Merge branch 'master' into plugin-settings-ui
SchrodingersGat Oct 1, 2024
d9082a2
Bump API version
SchrodingersGat Oct 1, 2024
6cef822
Merge branch 'plugin-settings-ui' of github.com:SchrodingersGat/Inven…
SchrodingersGat Oct 1, 2024
0628fb1
Merge branch 'master' into plugin-settings-ui
SchrodingersGat Oct 2, 2024
81f1da7
Add separate API endpoint for admin integration details
SchrodingersGat Oct 2, 2024
104a1d5
Refactor plugin drawer
SchrodingersGat Oct 2, 2024
2baac65
Null check
SchrodingersGat Oct 2, 2024
fd12ab1
Add playwright tests for custom admin integration
SchrodingersGat Oct 2, 2024
46850c3
Enable plugin panels in "settings" pages
SchrodingersGat Oct 2, 2024
d784ecc
Fix for unit test
SchrodingersGat Oct 2, 2024
f14b64c
Hide "Plugin Settings" for plugin without "settings" mixin
SchrodingersGat Oct 2, 2024
451d4a9
Add playwright test for custom admin integration
SchrodingersGat Oct 6, 2024
489c8a3
Fixes for playwright tests
SchrodingersGat Oct 6, 2024
8c5131b
Update playwright tests
SchrodingersGat Oct 6, 2024
3b67840
Merge branch 'master' into plugin-settings-ui
SchrodingersGat Oct 7, 2024
6ef2213
Merge branch 'master' into plugin-settings-ui
SchrodingersGat Oct 7, 2024
5fec281
Improved error message
SchrodingersGat Oct 7, 2024
43fce97
Merge branch 'plugin-settings-ui' of github.com:SchrodingersGat/Inven…
SchrodingersGat Oct 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading