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 10 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 = 263
INVENTREE_API_VERSION = 264

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


INVENTREE_API_TEXT = """

264 - 2024-10-02 : https://github.com/inventree/InvenTree/pull/8228
- Extend fields available in the PluginConfig API endpoints

263 - 2024-09-30 : https://github.com/inventree/InvenTree/pull/8194
- Adds Sales Order Shipment report

Expand Down
19 changes: 15 additions & 4 deletions src/backend/InvenTree/plugin/base/ui/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ 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

"""

# Optionally, specify the name of a javascript file which renders custom plugin admin panel
# This file should be provided in the 'static' directory of the plugin,
# and must provide a function named 'render_admin_panel'
ADMIN_PANEL_JS_FILE = None

class MixinMeta:
"""Metaclass for this plugin mixin."""

Expand Down Expand Up @@ -114,3 +115,13 @@ def get_ui_features(
"""
# Default implementation returns an empty list
return []

def get_ui_settings_file(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_PANEL_JS_FILE:
return None

return self.plugin_static_file(self.ADMIN_PANEL_JS_FILE)
21 changes: 21 additions & 0 deletions src/backend/InvenTree/plugin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ def is_package(self) -> bool:

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

@property
def admin_js_file(self) -> str:
"""Return the path to the javascript file which renders custom admin content for this plugin.

- The plugin must inherit the UserInterfaceMixin class to use this!
- 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_ui_settings_file'):
try:
return self.plugin.get_ui_settings_file()
except Exception:
pass

return None

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
7 changes: 6 additions & 1 deletion src/backend/InvenTree/plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,4 +445,9 @@ 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
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_PANEL_JS_FILE = 'ui_settings.js'

SETTINGS = {
'ENABLE_PART_PANELS': {
'name': _('Enable Part Panels'),
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@


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>
`;
}
5 changes: 5 additions & 0 deletions src/backend/InvenTree/plugin/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,18 @@ class Meta:
'is_sample',
'is_installed',
'is_package',
'admin_js_file',
]

read_only_fields = ['key', 'is_builtin', 'is_sample', 'is_installed']

meta = serializers.DictField(read_only=True)
mixins = serializers.DictField(read_only=True)

admin_js_file = serializers.CharField(
read_only=True, allow_null=True, label=_('Admin JS File')
)


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>
);
}
134 changes: 134 additions & 0 deletions src/frontend/src/components/plugins/PluginDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { t } from '@lingui/macro';
import { Accordion, Alert, Card, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';

import { InfoItem } from '../items/InfoItem';
import { StylishText } from '../items/StylishText';
import { PluginSettingList } from '../settings/SettingList';
import { PluginInterface } from './PluginInterface';
import PluginSettingsPanel from './PluginSettingsPanel';

/**
* Displays a drawer with detailed information on a specific plugin
*/
export default function PluginDrawer({
pluginKey,
pluginInstance
}: {
pluginKey: string;
pluginInstance: PluginInterface;
}) {
if (!pluginInstance.active) {
return (
<Alert
color="red"
title={t`Plugin Inactive`}
icon={<IconExclamationCircle />}
>
<Text>{t`Plugin is not active`}</Text>
</Alert>
);
}

return (
<>
<Accordion defaultValue={['plugin-details', 'plugin-settings']} multiple>
<Accordion.Item value="plugin-details">
<Accordion.Control>
<StylishText size="lg">{t`Plugin Information`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Card withBorder>
<Stack gap="md">
<Stack pos="relative" gap="xs">
<InfoItem
type="text"
name={t`Name`}
value={pluginInstance?.name}
/>
<InfoItem
type="text"
name={t`Description`}
value={pluginInstance?.meta.description}
/>
<InfoItem
type="text"
name={t`Author`}
value={pluginInstance?.meta.author}
/>
<InfoItem
type="text"
name={t`Date`}
value={pluginInstance?.meta.pub_date}
/>
<InfoItem
type="text"
name={t`Version`}
value={pluginInstance?.meta.version}
/>
<InfoItem
type="boolean"
name={t`Active`}
value={pluginInstance?.active}
/>
</Stack>
</Stack>
</Card>
<Card withBorder>
<Stack gap="md">
<Stack pos="relative" gap="xs">
{pluginInstance?.is_package && (
<InfoItem
type="text"
name={t`Package Name`}
value={pluginInstance?.package_name}
/>
)}
<InfoItem
type="text"
name={t`Installation Path`}
value={pluginInstance?.meta.package_path}
/>
<InfoItem
type="boolean"
name={t`Builtin`}
value={pluginInstance?.is_builtin}
/>
<InfoItem
type="boolean"
name={t`Package`}
value={pluginInstance?.is_package}
/>
</Stack>
</Stack>
</Card>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="plugin-settings">
<Accordion.Control>
<StylishText size="lg">{t`Plugin Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<PluginSettingList pluginKey={pluginKey} />
</Card>
</Accordion.Panel>
</Accordion.Item>
{pluginInstance.admin_js_file && (
<Accordion.Item value="plugin-custom">
<Accordion.Control>
<StylishText size="lg">{t`Plugin Configuration`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<PluginSettingsPanel pluginInstance={pluginInstance} />
</Card>
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
</>
);
}
34 changes: 34 additions & 0 deletions src/frontend/src/components/plugins/PluginInterface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Interface which defines a single plugin object
*/
export interface PluginInterface {
pk: number;
key: string;
name: string;
active: boolean;
is_builtin: boolean;
is_sample: boolean;
is_installed: boolean;
is_package: boolean;
package_name: string | null;
admin_js_file: string | null;
meta: {
author: string | null;
description: string | null;
human_name: string | null;
license: string | null;
package_path: string | null;
pub_date: string | null;
settings_url: string | null;
slug: string | null;
version: string | null;
website: string | null;
};
mixins: Record<
string,
{
key: string;
human_name: string;
}
>;
}
Loading
Loading