mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-08 08:18:50 +00:00
[PUI] Plugin settings UI (#8228)
* 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
This commit is contained in:
parent
36e3159c1a
commit
798e25a9dc
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
264 - 2024-10-03 : https://github.com/inventree/InvenTree/pull/8231
|
||||||
- Adds Sales Order Shipment attachment model type
|
- Adds Sales Order Shipment attachment model type
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
|
|||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
ListAPI,
|
ListAPI,
|
||||||
|
RetrieveAPI,
|
||||||
RetrieveDestroyAPI,
|
RetrieveDestroyAPI,
|
||||||
RetrieveUpdateAPI,
|
RetrieveUpdateAPI,
|
||||||
UpdateAPI,
|
UpdateAPI,
|
||||||
@ -177,6 +178,18 @@ class PluginDetail(RetrieveDestroyAPI):
|
|||||||
return super().delete(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):
|
class PluginInstall(CreateAPI):
|
||||||
"""Endpoint for installing a new plugin."""
|
"""Endpoint for installing a new plugin."""
|
||||||
|
|
||||||
@ -484,6 +497,9 @@ plugin_api_urls = [
|
|||||||
PluginUninstall.as_view(),
|
PluginUninstall.as_view(),
|
||||||
name='api-plugin-uninstall',
|
name='api-plugin-uninstall',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'admin/', PluginAdminDetail.as_view(), name='api-plugin-admin'
|
||||||
|
),
|
||||||
path('', PluginDetail.as_view(), name='api-plugin-detail'),
|
path('', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
@ -53,10 +53,6 @@ class UserInterfaceMixin:
|
|||||||
|
|
||||||
- All content is accessed via the API, as requested by the user interface.
|
- 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.
|
- 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:
|
class MixinMeta:
|
||||||
|
@ -87,7 +87,9 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
|||||||
self.assertNotIn('content', response.data[1])
|
self.assertNotIn('content', response.data[1])
|
||||||
|
|
||||||
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
|
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])
|
self.assertNotIn('content', response.data[2])
|
||||||
|
|
||||||
# Next, disable the global setting for UI integration
|
# Next, disable the global setting for UI integration
|
||||||
|
@ -187,6 +187,43 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
|||||||
|
|
||||||
return getattr(self.plugin, 'is_package', False)
|
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:
|
def activate(self, active: bool) -> None:
|
||||||
"""Set the 'active' status of this plugin instance."""
|
"""Set the 'active' status of this plugin instance."""
|
||||||
from InvenTree.tasks import check_for_migrations, offload_task
|
from InvenTree.tasks import check_for_migrations, offload_task
|
||||||
|
@ -220,6 +220,10 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
WEBSITE = None
|
WEBSITE = None
|
||||||
LICENSE = 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):
|
def __init__(self):
|
||||||
"""Init a plugin.
|
"""Init a plugin.
|
||||||
|
|
||||||
@ -445,4 +449,26 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
|
|
||||||
from django.conf import settings
|
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
|
||||||
|
@ -21,6 +21,8 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
|
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
|
||||||
VERSION = '1.1'
|
VERSION = '1.1'
|
||||||
|
|
||||||
|
ADMIN_SOURCE = 'ui_settings.js'
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'ENABLE_PART_PANELS': {
|
'ENABLE_PART_PANELS': {
|
||||||
'name': _('Enable Part Panels'),
|
'name': _('Enable Part Panels'),
|
||||||
@ -77,7 +79,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
})
|
})
|
||||||
|
|
||||||
# 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 self.get_setting('ENABLE_BROKEN_PANElS'):
|
if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
|
||||||
panels.append({
|
panels.append({
|
||||||
'name': 'broken_panel',
|
'name': 'broken_panel',
|
||||||
'label': 'Broken Panel',
|
'label': 'Broken Panel',
|
||||||
@ -90,7 +92,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
panels.append({
|
panels.append({
|
||||||
'name': 'dynamic_panel',
|
'name': 'dynamic_panel',
|
||||||
'label': 'Dynamic Part Panel',
|
'label': 'Dynamic Part Panel',
|
||||||
'source': '/static/plugin/sample_panel.js',
|
'source': self.plugin_static_file('sample_panel.js'),
|
||||||
'context': {
|
'context': {
|
||||||
'version': INVENTREE_SW_VERSION,
|
'version': INVENTREE_SW_VERSION,
|
||||||
'plugin_version': self.VERSION,
|
'plugin_version': self.VERSION,
|
||||||
@ -166,3 +168,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
]
|
]
|
||||||
|
|
||||||
return []
|
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'}
|
||||||
|
@ -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>
|
||||||
|
`;
|
||||||
|
}
|
@ -67,6 +67,31 @@ class PluginConfigSerializer(serializers.ModelSerializer):
|
|||||||
mixins = serializers.DictField(read_only=True)
|
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):
|
class PluginConfigInstallSerializer(serializers.Serializer):
|
||||||
"""Serializer for installing a new plugin."""
|
"""Serializer for installing a new plugin."""
|
||||||
|
|
||||||
|
@ -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 { IconSwitch } from '@tabler/icons-react';
|
||||||
import { ReactNode } from '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 {
|
interface SettingsHeaderInterface {
|
||||||
title: string | ReactNode;
|
label: string;
|
||||||
|
title: string;
|
||||||
shorthand?: string;
|
shorthand?: string;
|
||||||
subtitle?: string | ReactNode;
|
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
|
* Construct a settings page header with interlinks to one other settings page
|
||||||
*/
|
*/
|
||||||
export function SettingsHeader({
|
export function SettingsHeader({
|
||||||
|
label,
|
||||||
title,
|
title,
|
||||||
shorthand,
|
shorthand,
|
||||||
subtitle,
|
subtitle
|
||||||
switch_condition = true,
|
|
||||||
switch_text,
|
|
||||||
switch_link
|
|
||||||
}: Readonly<SettingsHeaderInterface>) {
|
}: Readonly<SettingsHeaderInterface>) {
|
||||||
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Group justify="space-between">
|
||||||
<Stack gap="0" ml={'sm'}>
|
<Stack gap="0" ml={'sm'}>
|
||||||
<Group>
|
<Group>
|
||||||
<Title order={3}>{title}</Title>
|
<StylishText size="xl">{title}</StylishText>
|
||||||
{shorthand && <Text c="dimmed">({shorthand})</Text>}
|
{shorthand && <Text c="dimmed">({shorthand})</Text>}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>{subtitle ? <Text c="dimmed">{subtitle}</Text> : null}</Group>
|
||||||
{subtitle ? <Text c="dimmed">{subtitle}</Text> : null}
|
</Stack>
|
||||||
{switch_text && switch_link && switch_condition && (
|
{user.isStaff() && (
|
||||||
<Anchor component={Link} to={switch_link}>
|
<SegmentedControl
|
||||||
<IconSwitch size={14} />
|
data={[
|
||||||
{switch_text}
|
{ value: 'user', label: t`User Settings` },
|
||||||
</Anchor>
|
{ value: 'system', label: t`System Settings` },
|
||||||
|
{ value: 'admin', label: t`Admin Center` }
|
||||||
|
]}
|
||||||
|
onChange={(value) => navigate(`/settings/${value}`)}
|
||||||
|
value={label}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
154
src/frontend/src/components/plugins/PluginDrawer.tsx
Normal file
154
src/frontend/src/components/plugins/PluginDrawer.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Accordion, Alert, Card, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
|
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;
|
||||||
|
}) {
|
||||||
|
const { instance: pluginAdmin } = useInstance({
|
||||||
|
endpoint: ApiEndpoints.plugin_admin,
|
||||||
|
pathParams: { key: pluginKey },
|
||||||
|
defaultValue: {},
|
||||||
|
hasPrimaryKey: false,
|
||||||
|
refetchOnMount: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSettings: boolean = useMemo(() => {
|
||||||
|
return !!pluginInstance?.mixins?.settings;
|
||||||
|
}, [pluginInstance]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
{hasSettings && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{pluginAdmin?.source && (
|
||||||
|
<Accordion.Item value="plugin-custom">
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size="lg">{t`Plugin Configuration`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Card withBorder>
|
||||||
|
<PluginSettingsPanel
|
||||||
|
pluginInstance={pluginInstance}
|
||||||
|
pluginAdmin={pluginAdmin}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
34
src/frontend/src/components/plugins/PluginInterface.tsx
Normal file
34
src/frontend/src/components/plugins/PluginInterface.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
@ -84,7 +84,7 @@ export default function PluginPanelContent({
|
|||||||
setError('');
|
setError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
t`Error occurred while rendering plugin content: ${error}`
|
t`Error occurred while rendering plugin content` + `: ${error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
86
src/frontend/src/components/plugins/PluginSettingsPanel.tsx
Normal file
86
src/frontend/src/components/plugins/PluginSettingsPanel.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Alert, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useInvenTreeContext } from './PluginContext';
|
||||||
|
import { findExternalPluginFunction } from './PluginSource';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the plugin admin data
|
||||||
|
*/
|
||||||
|
export interface PluginAdminInterface {
|
||||||
|
source: string;
|
||||||
|
context: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A panel which is used to display custom settings UI for a plugin.
|
||||||
|
*
|
||||||
|
* This settings panel is loaded dynamically,
|
||||||
|
* and requires that the plugin provides a javascript module,
|
||||||
|
* which exports a function `renderPluginSettings`
|
||||||
|
*/
|
||||||
|
export default function PluginSettingsPanel({
|
||||||
|
pluginInstance,
|
||||||
|
pluginAdmin
|
||||||
|
}: {
|
||||||
|
pluginInstance: any;
|
||||||
|
pluginAdmin: PluginAdminInterface;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>();
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const pluginContext = useInvenTreeContext();
|
||||||
|
|
||||||
|
const pluginSourceFile = useMemo(() => pluginAdmin?.source, [pluginInstance]);
|
||||||
|
|
||||||
|
const loadPluginSettingsContent = async () => {
|
||||||
|
if (pluginSourceFile) {
|
||||||
|
findExternalPluginFunction(pluginSourceFile, 'renderPluginSettings').then(
|
||||||
|
(func) => {
|
||||||
|
if (func) {
|
||||||
|
try {
|
||||||
|
func(ref.current, {
|
||||||
|
...pluginContext,
|
||||||
|
context: pluginAdmin.context
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
} catch (error) {
|
||||||
|
setError(
|
||||||
|
t`Error occurred while rendering plugin settings` + `: ${error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(t`Plugin did not provide settings rendering function`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPluginSettingsContent();
|
||||||
|
}, [pluginSourceFile]);
|
||||||
|
|
||||||
|
if (!pluginSourceFile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
title={t`Error Loading Plugin`}
|
||||||
|
icon={<IconExclamationCircle />}
|
||||||
|
>
|
||||||
|
<Text>{error}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div ref={ref as any}></div>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -20,7 +20,7 @@ export async function loadExternalPluginSource(source: string) {
|
|||||||
|
|
||||||
const module = await import(/* @vite-ignore */ source)
|
const module = await import(/* @vite-ignore */ source)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load plugin source:', error);
|
console.error(`ERR: Failed to load plugin from ${source}:`, error);
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.then((module) => {
|
.then((module) => {
|
||||||
|
@ -193,6 +193,7 @@ export enum ApiEndpoints {
|
|||||||
plugin_reload = 'plugins/reload/',
|
plugin_reload = 'plugins/reload/',
|
||||||
plugin_activate = 'plugins/:key/activate/',
|
plugin_activate = 'plugins/:key/activate/',
|
||||||
plugin_uninstall = 'plugins/:key/uninstall/',
|
plugin_uninstall = 'plugins/:key/uninstall/',
|
||||||
|
plugin_admin = 'plugins/:key/admin/',
|
||||||
|
|
||||||
// User interface plugin endpoints
|
// User interface plugin endpoints
|
||||||
plugin_panel_list = 'plugins/ui/panels/',
|
plugin_panel_list = 'plugins/ui/panels/',
|
||||||
|
@ -88,9 +88,7 @@ export function usePluginPanels({
|
|||||||
// This will force the plugin panels to re-calculate their visibility
|
// This will force the plugin panels to re-calculate their visibility
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pluginData?.forEach((props: PluginPanelProps) => {
|
pluginData?.forEach((props: PluginPanelProps) => {
|
||||||
const identifier = identifierString(
|
const identifier = identifierString(`${props.plugin}-${props.name}`);
|
||||||
`plugin-panel-${props.plugin}-${props.name}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the panel is hidden (defaults to true until we know otherwise)
|
// Check if the panel is hidden (defaults to true until we know otherwise)
|
||||||
isPluginPanelHidden({
|
isPluginPanelHidden({
|
||||||
@ -106,9 +104,7 @@ export function usePluginPanels({
|
|||||||
return (
|
return (
|
||||||
pluginData?.map((props: PluginPanelProps) => {
|
pluginData?.map((props: PluginPanelProps) => {
|
||||||
const iconName: string = props.icon || 'plugin';
|
const iconName: string = props.icon || 'plugin';
|
||||||
const identifier = identifierString(
|
const identifier = identifierString(`${props.plugin}-${props.name}`);
|
||||||
`plugin-panel-${props.plugin}-${props.name}`
|
|
||||||
);
|
|
||||||
const isHidden: boolean = panelState[identifier] ?? true;
|
const isHidden: boolean = panelState[identifier] ?? true;
|
||||||
|
|
||||||
const pluginContext: any = {
|
const pluginContext: any = {
|
||||||
|
@ -247,16 +247,17 @@ export default function AdminCenter() {
|
|||||||
{user.isStaff() ? (
|
{user.isStaff() ? (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
label="admin"
|
||||||
title={t`Admin Center`}
|
title={t`Admin Center`}
|
||||||
subtitle={t`Advanced Options`}
|
subtitle={t`Advanced Options`}
|
||||||
switch_link="/settings/system"
|
|
||||||
switch_text="System Settings"
|
|
||||||
/>
|
/>
|
||||||
<QuickAction />
|
<QuickAction />
|
||||||
<PanelGroup
|
<PanelGroup
|
||||||
pageKey="admin-center"
|
pageKey="admin-center"
|
||||||
panels={adminCenterPanels}
|
panels={adminCenterPanels}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
|
model="admincenter"
|
||||||
|
id={null}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
|
@ -306,12 +306,16 @@ export default function SystemSettings() {
|
|||||||
{user.isStaff() ? (
|
{user.isStaff() ? (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
label="system"
|
||||||
title={t`System Settings`}
|
title={t`System Settings`}
|
||||||
subtitle={server.instance || ''}
|
subtitle={server.instance || ''}
|
||||||
switch_link="/settings/user"
|
|
||||||
switch_text={<Trans>Switch to User Setting</Trans>}
|
|
||||||
/>
|
/>
|
||||||
<PanelGroup pageKey="system-settings" panels={systemSettingsPanels} />
|
<PanelGroup
|
||||||
|
pageKey="system-settings"
|
||||||
|
panels={systemSettingsPanels}
|
||||||
|
model="systemsettings"
|
||||||
|
id={null}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<PermissionDenied />
|
<PermissionDenied />
|
||||||
|
@ -148,6 +148,7 @@ export default function UserSettings() {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
label="user"
|
||||||
title={t`Account Settings`}
|
title={t`Account Settings`}
|
||||||
subtitle={
|
subtitle={
|
||||||
user?.first_name && user?.last_name
|
user?.first_name && user?.last_name
|
||||||
@ -155,11 +156,13 @@ export default function UserSettings() {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
shorthand={user?.username || ''}
|
shorthand={user?.username || ''}
|
||||||
switch_link="/settings/system"
|
|
||||||
switch_text={<Trans>Switch to System Setting</Trans>}
|
|
||||||
switch_condition={user?.is_staff || false}
|
|
||||||
/>
|
/>
|
||||||
<PanelGroup pageKey="user-settings" panels={userSettingsPanels} />
|
<PanelGroup
|
||||||
|
pageKey="user-settings"
|
||||||
|
panels={userSettingsPanels}
|
||||||
|
model="usersettings"
|
||||||
|
id={null}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,5 @@
|
|||||||
import { Trans, t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import { Alert, Group, Stack, Text, Tooltip } from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
LoadingOverlay,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
@ -26,16 +16,15 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||||
import { InfoItem } from '../../components/items/InfoItem';
|
|
||||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
||||||
import { PluginSettingList } from '../../components/settings/SettingList';
|
import PluginDrawer from '../../components/plugins/PluginDrawer';
|
||||||
|
import { PluginInterface } from '../../components/plugins/PluginInterface';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
useEditApiFormModal
|
useEditApiFormModal
|
||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl, useServerApiState } from '../../states/ApiState';
|
import { apiUrl, useServerApiState } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
@ -43,172 +32,10 @@ import { TableColumn } from '../Column';
|
|||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction } from '../RowActions';
|
import { RowAction } from '../RowActions';
|
||||||
|
|
||||||
export interface PluginI {
|
|
||||||
pk: number;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
active: boolean;
|
|
||||||
is_builtin: boolean;
|
|
||||||
is_sample: boolean;
|
|
||||||
is_installed: boolean;
|
|
||||||
is_package: boolean;
|
|
||||||
package_name: 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;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PluginDrawer({ pluginKey }: Readonly<{ pluginKey: string }>) {
|
|
||||||
const {
|
|
||||||
instance: plugin,
|
|
||||||
instanceQuery: { isFetching, error }
|
|
||||||
} = useInstance<PluginI>({
|
|
||||||
endpoint: ApiEndpoints.plugin_list,
|
|
||||||
hasPrimaryKey: true,
|
|
||||||
pk: pluginKey,
|
|
||||||
throwError: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!pluginKey || isFetching) {
|
|
||||||
return <LoadingOverlay visible={true} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plugin || error) {
|
|
||||||
return (
|
|
||||||
<Text>
|
|
||||||
{(error as any)?.response?.status === 404 ? (
|
|
||||||
<Trans>Plugin with key {pluginKey} not found</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>An error occurred while fetching plugin details</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={'xs'}>
|
|
||||||
<Card withBorder>
|
|
||||||
<Group justify="left">
|
|
||||||
<Box></Box>
|
|
||||||
|
|
||||||
<Group gap={'xs'}>
|
|
||||||
{plugin && <PluginIcon plugin={plugin} />}
|
|
||||||
<Title order={4}>
|
|
||||||
{plugin?.meta?.human_name ?? plugin?.name ?? '-'}
|
|
||||||
</Title>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
<LoadingOverlay visible={isFetching} overlayProps={{ opacity: 0 }} />
|
|
||||||
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Title order={4}>
|
|
||||||
<Trans>Plugin information</Trans>
|
|
||||||
</Title>
|
|
||||||
{plugin.active ? (
|
|
||||||
<Stack pos="relative" gap="xs">
|
|
||||||
<InfoItem type="text" name={t`Name`} value={plugin?.name} />
|
|
||||||
<InfoItem
|
|
||||||
type="text"
|
|
||||||
name={t`Description`}
|
|
||||||
value={plugin?.meta.description}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
type="text"
|
|
||||||
name={t`Author`}
|
|
||||||
value={plugin?.meta.author}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
type="text"
|
|
||||||
name={t`Date`}
|
|
||||||
value={plugin?.meta.pub_date}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
type="text"
|
|
||||||
name={t`Version`}
|
|
||||||
value={plugin?.meta.version}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
type="boolean"
|
|
||||||
name={t`Active`}
|
|
||||||
value={plugin?.active}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Text c="red">{t`Plugin is not active`}</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{plugin.active && (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Title order={4}>
|
|
||||||
<Trans>Package information</Trans>
|
|
||||||
</Title>
|
|
||||||
<Stack pos="relative" gap="xs">
|
|
||||||
{plugin?.is_package && (
|
|
||||||
<InfoItem
|
|
||||||
type="text"
|
|
||||||
name={t`Package Name`}
|
|
||||||
value={plugin?.package_name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<InfoItem
|
|
||||||
type="text"
|
|
||||||
name={t`Installation Path`}
|
|
||||||
value={plugin?.meta.package_path}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
type="boolean"
|
|
||||||
name={t`Builtin`}
|
|
||||||
value={plugin?.is_builtin}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
type="boolean"
|
|
||||||
name={t`Package`}
|
|
||||||
value={plugin?.is_package}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{plugin && plugin?.active && (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Title order={4}>
|
|
||||||
<Trans>Plugin settings</Trans>
|
|
||||||
</Title>
|
|
||||||
<PluginSettingList pluginKey={pluginKey} />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct an indicator icon for a single plugin
|
* Construct an indicator icon for a single plugin
|
||||||
*/
|
*/
|
||||||
function PluginIcon({ plugin }: Readonly<{ plugin: PluginI }>) {
|
function PluginIcon({ plugin }: Readonly<{ plugin: PluginInterface }>) {
|
||||||
if (plugin?.is_installed) {
|
if (plugin?.is_installed) {
|
||||||
if (plugin?.active) {
|
if (plugin?.active) {
|
||||||
return (
|
return (
|
||||||
@ -302,7 +129,8 @@ export default function PluginListTable() {
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
|
const [selectedPlugin, setSelectedPlugin] = useState<any>({});
|
||||||
|
const [selectedPluginKey, setSelectedPluginKey] = useState<string>('');
|
||||||
const [activate, setActivate] = useState<boolean>(false);
|
const [activate, setActivate] = useState<boolean>(false);
|
||||||
|
|
||||||
const activateModalContent = useMemo(() => {
|
const activateModalContent = useMemo(() => {
|
||||||
@ -345,7 +173,7 @@ export default function PluginListTable() {
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <IconCircleX />,
|
icon: <IconCircleX />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedPlugin(record.key);
|
setSelectedPluginKey(record.key);
|
||||||
setActivate(false);
|
setActivate(false);
|
||||||
activatePluginModal.open();
|
activatePluginModal.open();
|
||||||
}
|
}
|
||||||
@ -360,7 +188,7 @@ export default function PluginListTable() {
|
|||||||
color: 'green',
|
color: 'green',
|
||||||
icon: <IconCircleCheck />,
|
icon: <IconCircleCheck />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedPlugin(record.key);
|
setSelectedPluginKey(record.key);
|
||||||
setActivate(true);
|
setActivate(true);
|
||||||
activatePluginModal.open();
|
activatePluginModal.open();
|
||||||
}
|
}
|
||||||
@ -391,7 +219,7 @@ export default function PluginListTable() {
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <IconCircleX />,
|
icon: <IconCircleX />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedPlugin(record.key);
|
setSelectedPluginKey(record.key);
|
||||||
uninstallPluginModal.open();
|
uninstallPluginModal.open();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -409,7 +237,7 @@ export default function PluginListTable() {
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <IconTrash />,
|
icon: <IconTrash />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedPlugin(record.key);
|
setSelectedPluginKey(record.key);
|
||||||
deletePluginModal.open();
|
deletePluginModal.open();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -423,7 +251,7 @@ export default function PluginListTable() {
|
|||||||
const activatePluginModal = useEditApiFormModal({
|
const activatePluginModal = useEditApiFormModal({
|
||||||
title: t`Activate Plugin`,
|
title: t`Activate Plugin`,
|
||||||
url: ApiEndpoints.plugin_activate,
|
url: ApiEndpoints.plugin_activate,
|
||||||
pathParams: { key: selectedPlugin },
|
pathParams: { key: selectedPluginKey },
|
||||||
preFormContent: activateModalContent,
|
preFormContent: activateModalContent,
|
||||||
fetchInitialData: false,
|
fetchInitialData: false,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -463,7 +291,7 @@ export default function PluginListTable() {
|
|||||||
const uninstallPluginModal = useEditApiFormModal({
|
const uninstallPluginModal = useEditApiFormModal({
|
||||||
title: t`Uninstall Plugin`,
|
title: t`Uninstall Plugin`,
|
||||||
url: ApiEndpoints.plugin_uninstall,
|
url: ApiEndpoints.plugin_uninstall,
|
||||||
pathParams: { key: selectedPlugin },
|
pathParams: { key: selectedPluginKey },
|
||||||
fetchInitialData: false,
|
fetchInitialData: false,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
fields: {
|
fields: {
|
||||||
@ -487,7 +315,7 @@ export default function PluginListTable() {
|
|||||||
|
|
||||||
const deletePluginModal = useDeleteApiFormModal({
|
const deletePluginModal = useDeleteApiFormModal({
|
||||||
url: ApiEndpoints.plugin_list,
|
url: ApiEndpoints.plugin_list,
|
||||||
pk: selectedPlugin,
|
pk: selectedPluginKey,
|
||||||
fetchInitialData: false,
|
fetchInitialData: false,
|
||||||
title: t`Delete Plugin`,
|
title: t`Delete Plugin`,
|
||||||
preFormWarning: t`Deleting this plugin configuration will remove all associated settings and data. Are you sure you want to delete this plugin?`,
|
preFormWarning: t`Deleting this plugin configuration will remove all associated settings and data. Are you sure you want to delete this plugin?`,
|
||||||
@ -547,10 +375,15 @@ export default function PluginListTable() {
|
|||||||
{activatePluginModal.modal}
|
{activatePluginModal.modal}
|
||||||
<DetailDrawer
|
<DetailDrawer
|
||||||
title={t`Plugin Detail`}
|
title={t`Plugin Detail`}
|
||||||
size={'50%'}
|
size={'65%'}
|
||||||
renderContent={(pluginKey) => {
|
renderContent={(pluginKey) => {
|
||||||
if (!pluginKey) return;
|
if (!pluginKey) return;
|
||||||
return <PluginDrawer pluginKey={pluginKey} />;
|
return (
|
||||||
|
<PluginDrawer
|
||||||
|
pluginKey={pluginKey}
|
||||||
|
pluginInstance={selectedPlugin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
@ -560,7 +393,10 @@ export default function PluginListTable() {
|
|||||||
props={{
|
props={{
|
||||||
enableDownload: false,
|
enableDownload: false,
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
onRowClick: (plugin) => navigate(`${plugin.key}/`),
|
onRowClick: (plugin) => {
|
||||||
|
setSelectedPlugin(plugin);
|
||||||
|
navigate(`${plugin.key}/`);
|
||||||
|
},
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
tableFilters: [
|
tableFilters: [
|
||||||
{
|
{
|
||||||
|
@ -22,6 +22,11 @@ function ErrorDetail({ error }: { error: any }) {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t`Message`}</Table.Th>
|
<Table.Th>{t`Message`}</Table.Th>
|
||||||
<Table.Td>{error.info}</Table.Td>
|
<Table.Td>{error.info}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="right">
|
||||||
|
<CopyButton value={error.info} size="sm" />
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t`Timestamp`}</Table.Th>
|
<Table.Th>{t`Timestamp`}</Table.Th>
|
||||||
@ -33,7 +38,7 @@ function ErrorDetail({ error }: { error: any }) {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t`Traceback`}</Table.Th>
|
<Table.Th>{t`Traceback`}</Table.Th>
|
||||||
<Table.Td>
|
<Table.Td colSpan={2}>
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
<CopyButton value={error.data} size="sm" />
|
<CopyButton value={error.data} size="sm" />
|
||||||
</Group>
|
</Group>
|
||||||
|
@ -67,6 +67,8 @@ export const test = baseTest.extend({
|
|||||||
) < 0 &&
|
) < 0 &&
|
||||||
msg.text() !=
|
msg.text() !=
|
||||||
'Failed to load resource: the server responded with a status of 400 (Bad Request)' &&
|
'Failed to load resource: the server responded with a status of 400 (Bad Request)' &&
|
||||||
|
!msg.text().includes('http://localhost:8000/this/does/not/exist.js') &&
|
||||||
|
url != 'http://localhost:8000/this/does/not/exist.js' &&
|
||||||
url != 'http://localhost:8000/api/user/me/' &&
|
url != 'http://localhost:8000/api/user/me/' &&
|
||||||
url != 'http://localhost:8000/api/user/token/' &&
|
url != 'http://localhost:8000/api/user/token/' &&
|
||||||
url != 'http://localhost:8000/api/barcode/' &&
|
url != 'http://localhost:8000/api/barcode/' &&
|
||||||
|
@ -52,3 +52,37 @@ test('Plugins - Panels', async ({ page, request }) => {
|
|||||||
state: false
|
state: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit test for custom admin integration for plugins
|
||||||
|
*/
|
||||||
|
test('Plugins - Custom Admin', async ({ page, request }) => {
|
||||||
|
await doQuickLogin(page, 'admin', 'inventree');
|
||||||
|
|
||||||
|
// Ensure that the SampleUI plugin is enabled
|
||||||
|
await setPluginState({
|
||||||
|
request,
|
||||||
|
plugin: 'sampleui',
|
||||||
|
state: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to the "admin" page
|
||||||
|
await page.goto(`${baseUrl}/settings/admin/plugin/`);
|
||||||
|
|
||||||
|
// Open the plugin drawer, and ensure that the custom admin elements are visible
|
||||||
|
await page.getByText('SampleUI').click();
|
||||||
|
await page.getByRole('button', { name: 'Plugin Information' }).click();
|
||||||
|
await page
|
||||||
|
.getByLabel('Plugin Detail')
|
||||||
|
.getByRole('button', { name: 'Plugin Settings' })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('button', { name: 'Plugin Configuration' }).click();
|
||||||
|
|
||||||
|
// Check for expected custom elements
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Custom Plugin Configuration Content' })
|
||||||
|
.waitFor();
|
||||||
|
await page.getByText('apple: banana').waitFor();
|
||||||
|
await page.getByText('foo: bar').waitFor();
|
||||||
|
await page.getByText('hello: world').waitFor();
|
||||||
|
});
|
||||||
|
@ -21,7 +21,7 @@ test('Admin', async ({ page }) => {
|
|||||||
await page.getByText('Inline report display').waitFor();
|
await page.getByText('Inline report display').waitFor();
|
||||||
|
|
||||||
// System Settings
|
// System Settings
|
||||||
await page.getByRole('link', { name: 'Switch to System Setting' }).click();
|
await page.locator('label').filter({ hasText: 'System Settings' }).click();
|
||||||
await page.getByText('Base URL', { exact: true }).waitFor();
|
await page.getByText('Base URL', { exact: true }).waitFor();
|
||||||
await page.getByRole('tab', { name: 'Login' }).click();
|
await page.getByRole('tab', { name: 'Login' }).click();
|
||||||
await page.getByRole('tab', { name: 'Barcodes' }).click();
|
await page.getByRole('tab', { name: 'Barcodes' }).click();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user