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
|
||||
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
|
||||
|
||||
|
@ -21,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
ListAPI,
|
||||
RetrieveAPI,
|
||||
RetrieveDestroyAPI,
|
||||
RetrieveUpdateAPI,
|
||||
UpdateAPI,
|
||||
@ -177,6 +178,18 @@ class PluginDetail(RetrieveDestroyAPI):
|
||||
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."""
|
||||
|
||||
@ -484,6 +497,9 @@ plugin_api_urls = [
|
||||
PluginUninstall.as_view(),
|
||||
name='api-plugin-uninstall',
|
||||
),
|
||||
path(
|
||||
'admin/', PluginAdminDetail.as_view(), name='api-plugin-admin'
|
||||
),
|
||||
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.
|
||||
- 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:
|
||||
|
@ -87,7 +87,9 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
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
|
||||
|
@ -187,6 +187,43 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
|
||||
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
|
||||
|
@ -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.
|
||||
|
||||
@ -445,4 +449,26 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
|
||||
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'
|
||||
VERSION = '1.1'
|
||||
|
||||
ADMIN_SOURCE = 'ui_settings.js'
|
||||
|
||||
SETTINGS = {
|
||||
'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
|
||||
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',
|
||||
@ -90,7 +92,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
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,
|
||||
@ -166,3 +168,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
@ -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 (
|
||||
<Group justify="space-between">
|
||||
<Stack gap="0" ml={'sm'}>
|
||||
<Group>
|
||||
<Title order={3}>{title}</Title>
|
||||
<StylishText size="xl">{title}</StylishText>
|
||||
{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>{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>
|
||||
</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('');
|
||||
} catch (error) {
|
||||
setError(
|
||||
t`Error occurred while rendering plugin content: ${error}`
|
||||
t`Error occurred while rendering plugin content` + `: ${error}`
|
||||
);
|
||||
}
|
||||
} 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)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load plugin source:', error);
|
||||
console.error(`ERR: Failed to load plugin from ${source}:`, error);
|
||||
return null;
|
||||
})
|
||||
.then((module) => {
|
||||
|
@ -193,6 +193,7 @@ export enum ApiEndpoints {
|
||||
plugin_reload = 'plugins/reload/',
|
||||
plugin_activate = 'plugins/:key/activate/',
|
||||
plugin_uninstall = 'plugins/:key/uninstall/',
|
||||
plugin_admin = 'plugins/:key/admin/',
|
||||
|
||||
// User interface plugin endpoints
|
||||
plugin_panel_list = 'plugins/ui/panels/',
|
||||
|
@ -88,9 +88,7 @@ export function usePluginPanels({
|
||||
// This will force the plugin panels to re-calculate their visibility
|
||||
useEffect(() => {
|
||||
pluginData?.forEach((props: PluginPanelProps) => {
|
||||
const identifier = identifierString(
|
||||
`plugin-panel-${props.plugin}-${props.name}`
|
||||
);
|
||||
const identifier = identifierString(`${props.plugin}-${props.name}`);
|
||||
|
||||
// Check if the panel is hidden (defaults to true until we know otherwise)
|
||||
isPluginPanelHidden({
|
||||
@ -106,9 +104,7 @@ export function usePluginPanels({
|
||||
return (
|
||||
pluginData?.map((props: PluginPanelProps) => {
|
||||
const iconName: string = props.icon || 'plugin';
|
||||
const identifier = identifierString(
|
||||
`plugin-panel-${props.plugin}-${props.name}`
|
||||
);
|
||||
const identifier = identifierString(`${props.plugin}-${props.name}`);
|
||||
const isHidden: boolean = panelState[identifier] ?? true;
|
||||
|
||||
const pluginContext: any = {
|
||||
|
@ -247,16 +247,17 @@ export default function AdminCenter() {
|
||||
{user.isStaff() ? (
|
||||
<Stack gap="xs">
|
||||
<SettingsHeader
|
||||
label="admin"
|
||||
title={t`Admin Center`}
|
||||
subtitle={t`Advanced Options`}
|
||||
switch_link="/settings/system"
|
||||
switch_text="System Settings"
|
||||
/>
|
||||
<QuickAction />
|
||||
<PanelGroup
|
||||
pageKey="admin-center"
|
||||
panels={adminCenterPanels}
|
||||
collapsible={true}
|
||||
model="admincenter"
|
||||
id={null}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
|
@ -306,12 +306,16 @@ export default function SystemSettings() {
|
||||
{user.isStaff() ? (
|
||||
<Stack gap="xs">
|
||||
<SettingsHeader
|
||||
label="system"
|
||||
title={t`System Settings`}
|
||||
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>
|
||||
) : (
|
||||
<PermissionDenied />
|
||||
|
@ -148,6 +148,7 @@ export default function UserSettings() {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<SettingsHeader
|
||||
label="user"
|
||||
title={t`Account Settings`}
|
||||
subtitle={
|
||||
user?.first_name && user?.last_name
|
||||
@ -155,11 +156,13 @@ export default function UserSettings() {
|
||||
: null
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +1,5 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Group, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
@ -26,16 +16,15 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../App';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { InfoItem } from '../../components/items/InfoItem';
|
||||
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 {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl, useServerApiState } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@ -43,172 +32,10 @@ import { TableColumn } from '../Column';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
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
|
||||
*/
|
||||
function PluginIcon({ plugin }: Readonly<{ plugin: PluginI }>) {
|
||||
function PluginIcon({ plugin }: Readonly<{ plugin: PluginInterface }>) {
|
||||
if (plugin?.is_installed) {
|
||||
if (plugin?.active) {
|
||||
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 activateModalContent = useMemo(() => {
|
||||
@ -345,7 +173,7 @@ export default function PluginListTable() {
|
||||
color: 'red',
|
||||
icon: <IconCircleX />,
|
||||
onClick: () => {
|
||||
setSelectedPlugin(record.key);
|
||||
setSelectedPluginKey(record.key);
|
||||
setActivate(false);
|
||||
activatePluginModal.open();
|
||||
}
|
||||
@ -360,7 +188,7 @@ export default function PluginListTable() {
|
||||
color: 'green',
|
||||
icon: <IconCircleCheck />,
|
||||
onClick: () => {
|
||||
setSelectedPlugin(record.key);
|
||||
setSelectedPluginKey(record.key);
|
||||
setActivate(true);
|
||||
activatePluginModal.open();
|
||||
}
|
||||
@ -391,7 +219,7 @@ export default function PluginListTable() {
|
||||
color: 'red',
|
||||
icon: <IconCircleX />,
|
||||
onClick: () => {
|
||||
setSelectedPlugin(record.key);
|
||||
setSelectedPluginKey(record.key);
|
||||
uninstallPluginModal.open();
|
||||
}
|
||||
},
|
||||
@ -409,7 +237,7 @@ export default function PluginListTable() {
|
||||
color: 'red',
|
||||
icon: <IconTrash />,
|
||||
onClick: () => {
|
||||
setSelectedPlugin(record.key);
|
||||
setSelectedPluginKey(record.key);
|
||||
deletePluginModal.open();
|
||||
}
|
||||
}
|
||||
@ -423,7 +251,7 @@ export default function PluginListTable() {
|
||||
const activatePluginModal = useEditApiFormModal({
|
||||
title: t`Activate Plugin`,
|
||||
url: ApiEndpoints.plugin_activate,
|
||||
pathParams: { key: selectedPlugin },
|
||||
pathParams: { key: selectedPluginKey },
|
||||
preFormContent: activateModalContent,
|
||||
fetchInitialData: false,
|
||||
method: 'POST',
|
||||
@ -463,7 +291,7 @@ export default function PluginListTable() {
|
||||
const uninstallPluginModal = useEditApiFormModal({
|
||||
title: t`Uninstall Plugin`,
|
||||
url: ApiEndpoints.plugin_uninstall,
|
||||
pathParams: { key: selectedPlugin },
|
||||
pathParams: { key: selectedPluginKey },
|
||||
fetchInitialData: false,
|
||||
timeout: 30000,
|
||||
fields: {
|
||||
@ -487,7 +315,7 @@ export default function PluginListTable() {
|
||||
|
||||
const deletePluginModal = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.plugin_list,
|
||||
pk: selectedPlugin,
|
||||
pk: selectedPluginKey,
|
||||
fetchInitialData: false,
|
||||
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?`,
|
||||
@ -547,10 +375,15 @@ export default function PluginListTable() {
|
||||
{activatePluginModal.modal}
|
||||
<DetailDrawer
|
||||
title={t`Plugin Detail`}
|
||||
size={'50%'}
|
||||
size={'65%'}
|
||||
renderContent={(pluginKey) => {
|
||||
if (!pluginKey) return;
|
||||
return <PluginDrawer pluginKey={pluginKey} />;
|
||||
return (
|
||||
<PluginDrawer
|
||||
pluginKey={pluginKey}
|
||||
pluginInstance={selectedPlugin}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<InvenTreeTable
|
||||
@ -560,7 +393,10 @@ export default function PluginListTable() {
|
||||
props={{
|
||||
enableDownload: false,
|
||||
rowActions: rowActions,
|
||||
onRowClick: (plugin) => navigate(`${plugin.key}/`),
|
||||
onRowClick: (plugin) => {
|
||||
setSelectedPlugin(plugin);
|
||||
navigate(`${plugin.key}/`);
|
||||
},
|
||||
tableActions: tableActions,
|
||||
tableFilters: [
|
||||
{
|
||||
|
@ -22,6 +22,11 @@ function ErrorDetail({ error }: { error: any }) {
|
||||
<Table.Tr>
|
||||
<Table.Th>{t`Message`}</Table.Th>
|
||||
<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.Th>{t`Timestamp`}</Table.Th>
|
||||
@ -33,7 +38,7 @@ function ErrorDetail({ error }: { error: any }) {
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t`Traceback`}</Table.Th>
|
||||
<Table.Td>
|
||||
<Table.Td colSpan={2}>
|
||||
<Group justify="right">
|
||||
<CopyButton value={error.data} size="sm" />
|
||||
</Group>
|
||||
|
@ -67,6 +67,8 @@ export const test = baseTest.extend({
|
||||
) < 0 &&
|
||||
msg.text() !=
|
||||
'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/token/' &&
|
||||
url != 'http://localhost:8000/api/barcode/' &&
|
||||
|
@ -52,3 +52,37 @@ test('Plugins - Panels', async ({ page, request }) => {
|
||||
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();
|
||||
|
||||
// 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.getByRole('tab', { name: 'Login' }).click();
|
||||
await page.getByRole('tab', { name: 'Barcodes' }).click();
|
||||
|
Loading…
x
Reference in New Issue
Block a user