diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index e31ef825bb5..0a32a6ee044 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py
index ab7626e2afb..c20fd9d0ee4 100644
--- a/src/backend/InvenTree/plugin/api.py
+++ b/src/backend/InvenTree/plugin/api.py
@@ -21,6 +21,7 @@
from InvenTree.mixins import (
CreateAPI,
ListAPI,
+ RetrieveAPI,
RetrieveDestroyAPI,
RetrieveUpdateAPI,
UpdateAPI,
@@ -177,6 +178,18 @@ def delete(self, request, *args, **kwargs):
return super().delete(request, *args, **kwargs)
+class PluginAdminDetail(RetrieveAPI):
+ """Endpoint for viewing admin integration plugin details.
+
+ This endpoint is used to view the available admin integration options for a plugin.
+ """
+
+ queryset = PluginConfig.objects.all()
+ serializer_class = PluginSerializers.PluginAdminDetailSerializer
+ lookup_field = 'key'
+ lookup_url_kwarg = 'plugin'
+
+
class PluginInstall(CreateAPI):
"""Endpoint for installing a new plugin."""
@@ -484,6 +497,9 @@ class PluginMetadataView(MetadataView):
PluginUninstall.as_view(),
name='api-plugin-uninstall',
),
+ path(
+ 'admin/', PluginAdminDetail.as_view(), name='api-plugin-admin'
+ ),
path('', PluginDetail.as_view(), name='api-plugin-detail'),
]),
),
diff --git a/src/backend/InvenTree/plugin/base/ui/mixins.py b/src/backend/InvenTree/plugin/base/ui/mixins.py
index fb5ec8eac0e..21df4688a65 100644
--- a/src/backend/InvenTree/plugin/base/ui/mixins.py
+++ b/src/backend/InvenTree/plugin/base/ui/mixins.py
@@ -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:
diff --git a/src/backend/InvenTree/plugin/base/ui/tests.py b/src/backend/InvenTree/plugin/base/ui/tests.py
index faac8ba116c..0a5dc0af23c 100644
--- a/src/backend/InvenTree/plugin/base/ui/tests.py
+++ b/src/backend/InvenTree/plugin/base/ui/tests.py
@@ -87,7 +87,9 @@ def test_panels(self):
self.assertNotIn('content', response.data[1])
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
- self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js')
+ self.assertEqual(
+ response.data[2]['source'], '/static/plugins/sampleui/sample_panel.js'
+ )
self.assertNotIn('content', response.data[2])
# Next, disable the global setting for UI integration
diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py
index 4989f56af29..2d7af8860db 100644
--- a/src/backend/InvenTree/plugin/models.py
+++ b/src/backend/InvenTree/plugin/models.py
@@ -187,6 +187,43 @@ def is_package(self) -> bool:
return getattr(self.plugin, 'is_package', False)
+ @property
+ def admin_source(self) -> str:
+ """Return the path to the javascript file which renders custom admin content for this plugin.
+
+ - It is required that the file provides a 'renderPluginSettings' function!
+ """
+ if not self.plugin:
+ return None
+
+ if not self.is_installed() or not self.active:
+ return None
+
+ if hasattr(self.plugin, 'get_admin_source'):
+ try:
+ return self.plugin.get_admin_source()
+ except Exception:
+ pass
+
+ return None
+
+ @property
+ def admin_context(self) -> dict:
+ """Return the context data for the admin integration."""
+ if not self.plugin:
+ return None
+
+ if not self.is_installed() or not self.active:
+ return None
+
+ if hasattr(self.plugin, 'get_admin_context'):
+ try:
+ return self.plugin.get_admin_context()
+ except Exception:
+ pass
+
+ return {}
+
def activate(self, active: bool) -> None:
"""Set the 'active' status of this plugin instance."""
from InvenTree.tasks import check_for_migrations, offload_task
diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py
index ce3555720c1..4ead8f93028 100644
--- a/src/backend/InvenTree/plugin/plugin.py
+++ b/src/backend/InvenTree/plugin/plugin.py
@@ -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 @@ def plugin_static_file(self, *args):
from django.conf import settings
- return '/' + os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
+ url = os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
+
+ if not url.startswith('/'):
+ url = '/' + url
+
+ return url
+
+ def get_admin_source(self) -> str:
+ """Return a path to a JavaScript file which contains custom UI settings.
+
+ The frontend code expects that this file provides a function named 'renderPluginSettings'.
+ """
+ if not self.ADMIN_SOURCE:
+ return None
+
+ return self.plugin_static_file(self.ADMIN_SOURCE)
+
+ def get_admin_context(self) -> dict:
+ """Return a context dictionary for the admin panel settings.
+
+ This is an optional method which can be overridden by the plugin.
+ """
+ return None
diff --git a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
index 68766539286..fdb1dc57ec9 100644
--- a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
+++ b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
@@ -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 @@ def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs)
})
# A broken panel which tries to load a non-existent JS file
- if self.get_setting('ENABLE_BROKEN_PANElS'):
+ if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
panels.append({
'name': 'broken_panel',
'label': 'Broken Panel',
@@ -90,7 +92,7 @@ def get_ui_panels(self, instance_type: str, instance_id: int, request, **kwargs)
panels.append({
'name': 'dynamic_panel',
'label': 'Dynamic Part Panel',
- 'source': '/static/plugin/sample_panel.js',
+ 'source': self.plugin_static_file('sample_panel.js'),
'context': {
'version': INVENTREE_SW_VERSION,
'plugin_version': self.VERSION,
@@ -166,3 +168,7 @@ def get_ui_features(self, feature_type, context, request):
]
return []
+
+ def get_admin_context(self) -> dict:
+ """Return custom context data which can be rendered in the admin panel."""
+ return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
diff --git a/src/backend/InvenTree/plugin/samples/static/plugin/sample_panel.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js
similarity index 100%
rename from src/backend/InvenTree/plugin/samples/static/plugin/sample_panel.js
rename to src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js
diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/ui_settings.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/ui_settings.js
new file mode 100644
index 00000000000..85364ec8ed5
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/ui_settings.js
@@ -0,0 +1,16 @@
+
+
+export function renderPluginSettings(target, data) {
+
+ console.log("renderPluginSettings:", data);
+
+ target.innerHTML = `
+
Custom Plugin Configuration Content
+ Custom plugin configuration UI elements can be rendered here.
+
+ The following context data was provided by the server:
+
+ ${Object.entries(data.context).map(([key, value]) => `- ${key}: ${value}
`).join('')}
+
+ `;
+}
diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py
index eec4cd0420c..f0b12d6b069 100644
--- a/src/backend/InvenTree/plugin/serializers.py
+++ b/src/backend/InvenTree/plugin/serializers.py
@@ -67,6 +67,31 @@ class Meta:
mixins = serializers.DictField(read_only=True)
+class PluginAdminDetailSerializer(serializers.ModelSerializer):
+ """Serializer for a PluginConfig with admin details."""
+
+ class Meta:
+ """Metaclass options for serializer."""
+
+ model = PluginConfig
+
+ fields = ['source', 'context']
+
+ source = serializers.CharField(
+ allow_null=True,
+ label=_('Source File'),
+ help_text=_('Path to the source file for admin integration'),
+ source='admin_source',
+ )
+
+ context = serializers.JSONField(
+ allow_null=True,
+ label=_('Context'),
+ help_text=_('Optional context data for the admin integration'),
+ source='admin_context',
+ )
+
+
class PluginConfigInstallSerializer(serializers.Serializer):
"""Serializer for installing a new plugin."""
diff --git a/src/frontend/src/components/nav/SettingsHeader.tsx b/src/frontend/src/components/nav/SettingsHeader.tsx
index e5f776bd700..6d2bf95e5cd 100644
--- a/src/frontend/src/components/nav/SettingsHeader.tsx
+++ b/src/frontend/src/components/nav/SettingsHeader.tsx
@@ -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) {
+ const user = useUserState();
+ const navigate = useNavigate();
+
return (
-
-
- {title}
- {shorthand && ({shorthand})}
-
-
- {subtitle ? {subtitle} : null}
- {switch_text && switch_link && switch_condition && (
-
-
- {switch_text}
-
- )}
-
-
+
+
+
+ {title}
+ {shorthand && ({shorthand})}
+
+ {subtitle ? {subtitle} : null}
+
+ {user.isStaff() && (
+ navigate(`/settings/${value}`)}
+ value={label}
+ />
+ )}
+
);
}
diff --git a/src/frontend/src/components/plugins/PluginDrawer.tsx b/src/frontend/src/components/plugins/PluginDrawer.tsx
new file mode 100644
index 00000000000..f8415cce28c
--- /dev/null
+++ b/src/frontend/src/components/plugins/PluginDrawer.tsx
@@ -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 (
+ }
+ >
+ {t`Plugin is not active`}
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {t`Plugin Information`}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {pluginInstance?.is_package && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {hasSettings && (
+
+
+ {t`Plugin Settings`}
+
+
+
+
+
+
+
+ )}
+ {pluginAdmin?.source && (
+
+
+ {t`Plugin Configuration`}
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/frontend/src/components/plugins/PluginInterface.tsx b/src/frontend/src/components/plugins/PluginInterface.tsx
new file mode 100644
index 00000000000..36d9455bb80
--- /dev/null
+++ b/src/frontend/src/components/plugins/PluginInterface.tsx
@@ -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;
+ }
+ >;
+}
diff --git a/src/frontend/src/components/plugins/PluginPanel.tsx b/src/frontend/src/components/plugins/PluginPanel.tsx
index 431c9ba98cd..7e17a364f9d 100644
--- a/src/frontend/src/components/plugins/PluginPanel.tsx
+++ b/src/frontend/src/components/plugins/PluginPanel.tsx
@@ -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 {
diff --git a/src/frontend/src/components/plugins/PluginSettingsPanel.tsx b/src/frontend/src/components/plugins/PluginSettingsPanel.tsx
new file mode 100644
index 00000000000..309de7b6759
--- /dev/null
+++ b/src/frontend/src/components/plugins/PluginSettingsPanel.tsx
@@ -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();
+ const [error, setError] = useState(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 (
+ <>
+
+ {error && (
+ }
+ >
+ {error}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/frontend/src/components/plugins/PluginSource.tsx b/src/frontend/src/components/plugins/PluginSource.tsx
index 69239866e38..580496fbf6a 100644
--- a/src/frontend/src/components/plugins/PluginSource.tsx
+++ b/src/frontend/src/components/plugins/PluginSource.tsx
@@ -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) => {
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 49d100322f0..3a3044c3f95 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -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/',
diff --git a/src/frontend/src/hooks/UsePluginPanels.tsx b/src/frontend/src/hooks/UsePluginPanels.tsx
index 1cf87f19baa..b015db48661 100644
--- a/src/frontend/src/hooks/UsePluginPanels.tsx
+++ b/src/frontend/src/hooks/UsePluginPanels.tsx
@@ -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 = {
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
index be2aeb142a5..0a59c839147 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
@@ -247,16 +247,17 @@ export default function AdminCenter() {
{user.isStaff() ? (
) : (
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index 50023dc5eee..4a4efbb5fe7 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -306,12 +306,16 @@ export default function SystemSettings() {
{user.isStaff() ? (
Switch to User Setting}
/>
-
+
) : (
diff --git a/src/frontend/src/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx
index a74cb755cce..8312f741ed6 100644
--- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx
@@ -148,6 +148,7 @@ export default function UserSettings() {
return (
Switch to System Setting}
- switch_condition={user?.is_staff || false}
/>
-
+
);
}
diff --git a/src/frontend/src/tables/plugin/PluginListTable.tsx b/src/frontend/src/tables/plugin/PluginListTable.tsx
index 2f187627363..9ad48a47703 100644
--- a/src/frontend/src/tables/plugin/PluginListTable.tsx
+++ b/src/frontend/src/tables/plugin/PluginListTable.tsx
@@ -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({
- endpoint: ApiEndpoints.plugin_list,
- hasPrimaryKey: true,
- pk: pluginKey,
- throwError: true
- });
-
- if (!pluginKey || isFetching) {
- return ;
- }
-
- if (!plugin || error) {
- return (
-
- {(error as any)?.response?.status === 404 ? (
- Plugin with key {pluginKey} not found
- ) : (
- An error occurred while fetching plugin details
- )}
-
- );
- }
-
- return (
-
-
-
-
-
-
- {plugin && }
-
- {plugin?.meta?.human_name ?? plugin?.name ?? '-'}
-
-
-
-
-
-
-
-
-
- Plugin information
-
- {plugin.active ? (
-
-
-
-
-
-
-
-
- ) : (
- {t`Plugin is not active`}
- )}
-
-
-
- {plugin.active && (
-
-
-
- Package information
-
-
- {plugin?.is_package && (
-
- )}
-
-
-
-
-
-
- )}
-
- {plugin && plugin?.active && (
-
-
-
- Plugin settings
-
-
-
-
- )}
-
- );
-}
-
/**
* 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('');
+ const [selectedPlugin, setSelectedPlugin] = useState({});
+ const [selectedPluginKey, setSelectedPluginKey] = useState('');
const [activate, setActivate] = useState(false);
const activateModalContent = useMemo(() => {
@@ -345,7 +173,7 @@ export default function PluginListTable() {
color: 'red',
icon: ,
onClick: () => {
- setSelectedPlugin(record.key);
+ setSelectedPluginKey(record.key);
setActivate(false);
activatePluginModal.open();
}
@@ -360,7 +188,7 @@ export default function PluginListTable() {
color: 'green',
icon: ,
onClick: () => {
- setSelectedPlugin(record.key);
+ setSelectedPluginKey(record.key);
setActivate(true);
activatePluginModal.open();
}
@@ -391,7 +219,7 @@ export default function PluginListTable() {
color: 'red',
icon: ,
onClick: () => {
- setSelectedPlugin(record.key);
+ setSelectedPluginKey(record.key);
uninstallPluginModal.open();
}
},
@@ -409,7 +237,7 @@ export default function PluginListTable() {
color: 'red',
icon: ,
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}
{
if (!pluginKey) return;
- return ;
+ return (
+
+ );
}}
/>
navigate(`${plugin.key}/`),
+ onRowClick: (plugin) => {
+ setSelectedPlugin(plugin);
+ navigate(`${plugin.key}/`);
+ },
tableActions: tableActions,
tableFilters: [
{
diff --git a/src/frontend/src/tables/settings/ErrorTable.tsx b/src/frontend/src/tables/settings/ErrorTable.tsx
index 781f57b3c09..9f3ebffc443 100644
--- a/src/frontend/src/tables/settings/ErrorTable.tsx
+++ b/src/frontend/src/tables/settings/ErrorTable.tsx
@@ -22,6 +22,11 @@ function ErrorDetail({ error }: { error: any }) {
{t`Message`}
{error.info}
+
+
+
+
+
{t`Timestamp`}
@@ -33,7 +38,7 @@ function ErrorDetail({ error }: { error: any }) {
{t`Traceback`}
-
+
diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts
index 39c1e7fb55f..02f729d2a73 100644
--- a/src/frontend/tests/baseFixtures.ts
+++ b/src/frontend/tests/baseFixtures.ts
@@ -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/' &&
diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts
index 53c92b1c34c..51e35bf2bda 100644
--- a/src/frontend/tests/pui_plugins.spec.ts
+++ b/src/frontend/tests/pui_plugins.spec.ts
@@ -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();
+});
diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts
index 7e216f478dc..2305f467f4c 100644
--- a/src/frontend/tests/pui_settings.spec.ts
+++ b/src/frontend/tests/pui_settings.spec.ts
@@ -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();