2
0
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:
Oliver 2024-10-07 22:25:56 +11:00 committed by GitHub
parent 36e3159c1a
commit 798e25a9dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 540 additions and 242 deletions

View File

@ -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

View File

@ -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'),
]), ]),
), ),

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'}

View File

@ -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>
`;
}

View File

@ -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."""

View File

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

View 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>
</>
);
}

View 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;
}
>;
}

View File

@ -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 {

View 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>
</>
);
}

View File

@ -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) => {

View File

@ -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/',

View File

@ -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 = {

View File

@ -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>
) : ( ) : (

View File

@ -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 />

View File

@ -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>
); );
} }

View File

@ -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: [
{ {

View File

@ -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>

View File

@ -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/' &&

View File

@ -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();
});

View File

@ -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();