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:

+ + `; +} 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 ?? '-'} - - - - - - - - - - <Trans>Plugin information</Trans> - - {plugin.active ? ( - - - - - - - - - ) : ( - {t`Plugin is not active`} - )} - - - - {plugin.active && ( - - - - <Trans>Package information</Trans> - - - {plugin?.is_package && ( - - )} - - - - - - - )} - - {plugin && plugin?.active && ( - - - - <Trans>Plugin settings</Trans> - - - - - )} - - ); -} - /** * 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();