2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 03:25:42 +00:00

initial implementation to let plugins provide custom ui features

This commit is contained in:
wolflu05
2024-09-17 20:36:25 +02:00
parent 9aab352a7e
commit e235464065
16 changed files with 487 additions and 138 deletions

View File

@ -20,7 +20,7 @@ When rendering certain content in the user interface, the rendering functions ar
Many of the pages in the InvenTree web interface are built using a series of "panels" which are displayed on the page. Custom panels can be added to these pages, by implementing the `get_custom_panels` method:
::: plugin.base.integration.UserInterfaceMixin.UserInterfaceMixin.get_custom_panels
::: plugin.base.ui.mixins.UserInterfaceMixin.get_custom_panels
options:
show_bases: False
show_root_heading: False

View File

@ -11,13 +11,11 @@ from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
import plugin.serializers as PluginSerializers
from common.api import GlobalSettingsPermissions
from common.settings import get_global_setting
from InvenTree.api import MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
@ -32,6 +30,7 @@ from plugin import registry
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView
from plugin.base.ui.api import ui_plugins_api_urls
from plugin.models import PluginConfig, PluginSetting
from plugin.plugin import InvenTreePlugin
@ -416,38 +415,6 @@ class RegistryStatusView(APIView):
return Response(result)
class PluginPanelList(APIView):
"""API endpoint for listing all available plugin panels."""
permission_classes = [IsAuthenticated]
serializer_class = PluginSerializers.PluginPanelSerializer
@extend_schema(responses={200: PluginSerializers.PluginPanelSerializer(many=True)})
def get(self, request):
"""Show available plugin panels."""
target_model = request.query_params.get('target_model', None)
target_id = request.query_params.get('target_id', None)
panels = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom panels
for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out
plugin_panels = _plugin.get_custom_panels(
target_model, target_id, request
)
if plugin_panels and type(plugin_panels) is list:
for panel in plugin_panels:
panel['plugin'] = _plugin.slug
# TODO: Validate each panel before inserting
panels.append(panel)
return Response(PluginSerializers.PluginPanelSerializer(panels, many=True).data)
class PluginMetadataView(MetadataView):
"""Metadata API endpoint for the PluginConfig model."""
@ -462,21 +429,8 @@ plugin_api_urls = [
path(
'plugins/',
include([
path(
'ui/',
include([
path(
'panels/',
include([
path(
'',
PluginPanelList.as_view(),
name='api-plugin-panel-list',
)
]),
)
]),
),
# UI plugins
path('ui/', include(ui_plugins_api_urls)),
# Plugin management
path('reload/', PluginReload.as_view(), name='api-plugin-reload'),
path('install/', PluginInstall.as_view(), name='api-plugin-install'),

View File

@ -0,0 +1,90 @@
"""API for UI plugins."""
from django.urls import path
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
import plugin.base.ui.serializers as UIPluginSerializers
from common.settings import get_global_setting
from plugin import registry
class PluginPanelList(APIView):
"""API endpoint for listing all available plugin panels."""
permission_classes = [IsAuthenticated]
serializer_class = UIPluginSerializers.PluginPanelSerializer
@extend_schema(
responses={200: UIPluginSerializers.PluginPanelSerializer(many=True)}
)
def get(self, request):
"""Show available plugin panels."""
target_model = request.query_params.get('target_model', None)
target_id = request.query_params.get('target_id', None)
panels = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom panels
for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out
plugin_panels = _plugin.get_custom_panels(
target_model, target_id, request
)
if plugin_panels and type(plugin_panels) is list:
for panel in plugin_panels:
panel['plugin'] = _plugin.slug
# TODO: Validate each panel before inserting
panels.append(panel)
return Response(
UIPluginSerializers.PluginPanelSerializer(panels, many=True).data
)
class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features."""
permission_classes = [IsAuthenticated]
serializer_class = UIPluginSerializers.PluginUIFeatureSerializer
@extend_schema(
responses={200: UIPluginSerializers.PluginUIFeatureSerializer(many=True)}
)
def get(self, request, feature):
"""Show available plugin ui features."""
features = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom ui features
for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out
plugin_features = _plugin.get_ui_features(
feature, request.query_params, request
)
if not plugin_features or type(plugin_features) is not list:
continue
for feature in plugin_features:
features.append(feature)
return Response(
UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data
)
ui_plugins_api_urls = [
path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'),
path(
'features/<str:feature>/',
PluginUIFeatureList.as_view(),
name='api-plugin-ui-feature-list',
),
]

View File

@ -4,7 +4,7 @@ Allows integration of custom UI elements into the React user interface.
"""
import logging
from typing import TypedDict
from typing import Literal, TypedDict
from rest_framework.request import Request
@ -29,6 +29,23 @@ class CustomPanel(TypedDict):
source: str
FeatureType = Literal['template_editor', 'template_preview']
class UIFeature(TypedDict):
"""Base type definition for a ui feature.
Attributes:
feature_type: The feature type (required, see documentation for all available types)
options: Feature options (required, see documentation for all available options for each type)
source: The source of the feature (required, path to a JavaScript file).
"""
feature_type: FeatureType
options: dict
source: str
class UserInterfaceMixin:
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
@ -44,7 +61,7 @@ class UserInterfaceMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('ui', True, __class__)
self.add_mixin('ui', True, __class__) # type: ignore
def get_custom_panels(
self, instance_type: str, instance_id: int, request: Request
@ -74,3 +91,19 @@ class UserInterfaceMixin:
"""
# Default implementation returns an empty list
return []
def get_ui_features(
self, feature_type: FeatureType, context: dict, request: Request
) -> list[UIFeature]:
"""Return a list of custom features to be injected into the UI.
Arguments:
feature_type: The type of feature being requested
context: Additional context data provided by the UI
request: HTTPRequest object (including user information)
Returns:
list: A list of custom UIFeature dicts to be injected into the UI
"""
# Default implementation returns an empty list
return []

View File

@ -0,0 +1,68 @@
"""Serializers for UI plugin api."""
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class PluginPanelSerializer(serializers.Serializer):
"""Serializer for a plugin panel."""
class Meta:
"""Meta for serializer."""
fields = [
'plugin',
'name',
'label',
# Following fields are optional
'icon',
'content',
'source',
]
# Required fields
plugin = serializers.CharField(
label=_('Plugin Key'), required=True, allow_blank=False
)
name = serializers.CharField(
label=_('Panel Name'), required=True, allow_blank=False
)
label = serializers.CharField(
label=_('Panel Title'), required=True, allow_blank=False
)
# Optional fields
icon = serializers.CharField(
label=_('Panel Icon'), required=False, allow_blank=True
)
content = serializers.CharField(
label=_('Panel Content (HTML)'), required=False, allow_blank=True
)
source = serializers.CharField(
label=_('Panel Source (javascript)'), required=False, allow_blank=True
)
class PluginUIFeatureSerializer(serializers.Serializer):
"""Serializer for a plugin ui feature."""
class Meta:
"""Meta for serializer."""
fields = ['feature_type', 'options', 'source']
# Required fields
feature_type = serializers.CharField(
label=_('Feature Type'), required=True, allow_blank=False
)
options = serializers.DictField(label=_('Feature Options'), required=True)
source = serializers.CharField(
label=_('Feature Source (javascript)'), required=True, allow_blank=False
)

View File

@ -14,10 +14,10 @@ from plugin.base.integration.ReportMixin import ReportMixin
from plugin.base.integration.ScheduleMixin import ScheduleMixin
from plugin.base.integration.SettingsMixin import SettingsMixin
from plugin.base.integration.UrlsMixin import UrlsMixin
from plugin.base.integration.UserInterfaceMixin import UserInterfaceMixin
from plugin.base.integration.ValidationMixin import ValidationMixin
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.base.locate.mixins import LocateMixin
from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [
'APICallMixin',

View File

@ -301,46 +301,3 @@ class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
"""Return the 'key' of the PluginConfig object."""
return value.key
class PluginPanelSerializer(serializers.Serializer):
"""Serializer for a plugin panel."""
class Meta:
"""Meta for serializer."""
fields = [
'plugin',
'name',
'label',
# Following fields are optional
'icon',
'content',
'source',
]
# Required fields
plugin = serializers.CharField(
label=_('Plugin Key'), required=True, allow_blank=False
)
name = serializers.CharField(
label=_('Panel Name'), required=True, allow_blank=False
)
label = serializers.CharField(
label=_('Panel Title'), required=True, allow_blank=False
)
# Optional fields
icon = serializers.CharField(
label=_('Panel Icon'), required=False, allow_blank=True
)
content = serializers.CharField(
label=_('Panel Content (HTML)'), required=False, allow_blank=True
)
source = serializers.CharField(
label=_('Panel Source (javascript)'), required=False, allow_blank=True
)

View File

@ -1,10 +1,22 @@
import { MantineColorScheme, MantineTheme } from '@mantine/core';
import {
MantineColorScheme,
MantineTheme,
useMantineColorScheme,
useMantineTheme
} from '@mantine/core';
import { AxiosInstance } from 'axios';
import { NavigateFunction } from 'react-router-dom';
import { useMemo } from 'react';
import { NavigateFunction, useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ModelType } from '../../enums/ModelType';
import { SettingsStateProps } from '../../states/SettingsState';
import { UserStateProps } from '../../states/UserState';
import { useLocalState } from '../../states/LocalState';
import {
SettingsStateProps,
useGlobalSettingsState,
useUserSettingsState
} from '../../states/SettingsState';
import { UserStateProps, useUserState } from '../../states/UserState';
/*
* A set of properties which are passed to a plugin,
@ -34,3 +46,37 @@ export type PluginContext = {
theme: MantineTheme;
colorScheme: MantineColorScheme;
};
export const usePluginContext = () => {
const host = useLocalState.getState().host;
const navigate = useNavigate();
const user = useUserState();
const { colorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
const contextData: PluginContext = useMemo(() => {
return {
user: user,
host: host,
api: api,
navigate: navigate,
globalSettings: globalSettings,
userSettings: userSettings,
theme: theme,
colorScheme: colorScheme
};
}, [
user,
host,
api,
navigate,
globalSettings,
userSettings,
theme,
colorScheme
]);
return contextData;
};

View File

@ -0,0 +1,99 @@
import { t } from '@lingui/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import { TemplateI } from '../../tables/settings/TemplateTable';
import { EditorComponent } from '../editors/TemplateEditor/TemplateEditor';
// Definition of the plugin ui feature properties, provided by the server API
export type PluginUIFeatureProps = (
| {
feature_type: 'template_editor';
options: {
title: string;
slug: string;
};
}
| {
feature_type: 'template_preview';
options: {
title: string;
slug: string;
};
}
) & {
source: string;
};
export type TemplateEditorRenderContextType = {
registerHandlers: (params: {
setCode: (code: string) => void;
getCode: () => string;
}) => void;
template: TemplateI;
};
export const getPluginTemplateEditor = (func: any, template: any) =>
forwardRef((props, ref) => {
const elRef = useRef<HTMLDivElement>();
const [error, setError] = useState<string | undefined>(undefined);
const initialCodeRef = useRef<string>();
const setCodeRef = useRef<(code: string) => void>();
const getCodeRef = useRef<() => string>();
useImperativeHandle(ref, () => ({
setCode: (code) => {
// if the editor is not yet initialized, store the initial code in a ref to set it later
if (setCodeRef.current) {
setCodeRef.current(code);
} else {
initialCodeRef.current = code;
}
},
getCode: () => getCodeRef.current?.()
}));
useEffect(() => {
(async () => {
try {
await func(elRef.current, {
registerHandlers: ({ getCode, setCode }) => {
setCodeRef.current = setCode;
getCodeRef.current = getCode;
if (initialCodeRef.current) {
setCode(initialCodeRef.current);
}
},
template
} as TemplateEditorRenderContextType);
} catch (error) {
setError(t`Error occurred while rendering the template editor.`);
console.error(error);
}
})();
}, []);
return (
<Stack gap="xs" style={{ display: 'flex', flex: 1 }}>
{error && (
<Alert
color="red"
title={t`Error Loading Plugin`}
icon={<IconExclamationCircle />}
>
<Text>{error}</Text>
</Alert>
)}
<div ref={elRef as any} style={{ display: 'flex', flex: 1 }}></div>
</Stack>
);
}) as EditorComponent;

View File

@ -184,6 +184,7 @@ export enum ApiEndpoints {
// User interface plugin endpoints
plugin_panel_list = 'plugins/ui/panels/',
plugin_ui_features_list = 'plugins/ui/features/:feature_type/',
// Machine API endpoints
machine_types_list = 'machine/types/',

View File

@ -1,11 +1,12 @@
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import { PanelType } from '../components/nav/Panel';
import { PluginContext } from '../components/plugins/PluginContext';
import {
PluginContext,
usePluginContext
} from '../components/plugins/PluginContext';
import PluginPanelContent, {
PluginPanelProps,
isPluginPanelHidden
@ -15,12 +16,7 @@ import { ModelType } from '../enums/ModelType';
import { identifierString } from '../functions/conversion';
import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons';
import { apiUrl } from '../states/ApiState';
import { useLocalState } from '../states/LocalState';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../states/SettingsState';
import { useUserState } from '../states/UserState';
import { useGlobalSettingsState } from '../states/SettingsState';
export function usePluginPanels({
instance,
@ -31,13 +27,7 @@ export function usePluginPanels({
model?: ModelType | string;
id?: string | number | null;
}): PanelType[] {
const host = useLocalState.getState().host;
const navigate = useNavigate();
const user = useUserState();
const { colorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
const pluginPanelsEnabled: boolean = useMemo(
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
@ -69,33 +59,15 @@ export function usePluginPanels({
});
// Cache the context data which is delivered to the plugins
const pluginContext = usePluginContext();
const contextData: PluginContext = useMemo(() => {
return {
model: model,
id: id,
instance: instance,
user: user,
host: host,
api: api,
navigate: navigate,
globalSettings: globalSettings,
userSettings: userSettings,
theme: theme,
colorScheme: colorScheme
...pluginContext
};
}, [
model,
id,
instance,
user,
host,
api,
navigate,
globalSettings,
userSettings,
theme,
colorScheme
]);
}, [model, id, instance]);
// Track which panels are hidden: { panelName: true/false }
// We need to memoize this as the plugins can determine this dynamically

View File

@ -0,0 +1,83 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from '../App';
import {
PluginContext,
usePluginContext
} from '../components/plugins/PluginContext';
import { findExternalPluginFunction } from '../components/plugins/PluginSource';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
export function usePluginUIFeature<
RequestContextT extends Record<string, any>,
ResponseOptionsT extends Record<string, any>,
RenderContextT extends Record<string, any>
>({ featureType, context }: { featureType: string; context: RequestContextT }) {
const globalSettings = useGlobalSettingsState();
const pluginUiFeaturesEnabled: boolean = useMemo(
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
[globalSettings]
);
// API query to fetch initial information on available plugin panels
const { data: pluginData } = useQuery({
enabled: pluginUiFeaturesEnabled && !!featureType,
queryKey: ['custom-ui-features', featureType, JSON.stringify(context)],
queryFn: async () => {
if (!pluginUiFeaturesEnabled || !featureType) {
return Promise.resolve([]);
}
return api
.get(
apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
feature_type: featureType
}),
{
params: context
}
)
.then((response: any) => response.data)
.catch((error: any) => {
console.error(
`Failed to fetch plugin ui features for feature "${featureType}":`,
error
);
return [];
});
}
});
// Cache the context data which is delivered to the plugins
const pluginContext = usePluginContext();
return useMemo<
{
options: ResponseOptionsT;
func: (
ref: HTMLDivElement,
params: { renderContext: RenderContextT; pluginContext: PluginContext }
) => void;
}[]
>(() => {
return (
pluginData?.map((feature: any) => ({
options: feature.options,
func: async (ref: HTMLDivElement, renderContext: RenderContextT) => {
const func = await findExternalPluginFunction(
feature.source,
'getFeature'
);
return func(ref, {
renderContext,
pluginContext
});
}
})) || []
);
}, [pluginData, pluginContext]);
}

View File

@ -6,6 +6,7 @@ export default function LabelTemplatePanel() {
return (
<TemplateTable
templateProps={{
templateType: 'label',
modelType: ModelType.labeltemplate,
templateEndpoint: ApiEndpoints.label_list,
printingEndpoint: ApiEndpoints.label_print,

View File

@ -9,6 +9,7 @@ export default function ReportTemplateTable() {
return (
<TemplateTable
templateProps={{
templateType: 'report',
modelType: ModelType.reporttemplate,
templateEndpoint: ApiEndpoints.report_list,
printingEndpoint: ApiEndpoints.report_print,

View File

@ -10,9 +10,14 @@ import {
PdfPreview,
TemplateEditor
} from '../../components/editors/TemplateEditor';
import { Editor } from '../../components/editors/TemplateEditor/TemplateEditor';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { AttachmentLink } from '../../components/items/AttachmentLink';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import {
TemplateEditorRenderContextType,
getPluginTemplateEditor
} from '../../components/plugins/PluginUIFeature';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { notYetImplemented } from '../../functions/notifications';
@ -23,6 +28,7 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -49,6 +55,7 @@ export type TemplateI = {
};
export interface TemplateProps {
templateType: 'label' | 'report';
modelType: ModelType;
templateEndpoint: ApiEndpoints;
printingEndpoint: ApiEndpoints;
@ -62,7 +69,8 @@ export function TemplateDrawer({
id: string | number;
templateProps: TemplateProps;
}) {
const { templateEndpoint, printingEndpoint } = templateProps;
const { modelType, templateType, templateEndpoint, printingEndpoint } =
templateProps;
const {
instance: template,
@ -74,6 +82,42 @@ export function TemplateDrawer({
throwError: true
});
const extraEditors = usePluginUIFeature<
{
template_type: string;
template_model: string;
},
{
title: string;
slug: string;
},
TemplateEditorRenderContextType
>({
featureType: 'template_editor',
context: { template_type: templateType, template_model: modelType }
});
const editors = useMemo(() => {
const editors = [CodeEditor];
if (!template) {
return editors;
}
editors.push(
...(extraEditors?.map(
(editor) =>
({
key: editor.options.slug,
name: editor.options.title,
icon: IconFileCode,
component: getPluginTemplateEditor(editor.func, template)
} as Editor)
) || [])
);
return editors;
}, [extraEditors, template]);
if (isFetching) {
return <LoadingOverlay visible={true} />;
}
@ -100,7 +144,7 @@ export function TemplateDrawer({
templateUrl={apiUrl(templateEndpoint, id)}
printingUrl={apiUrl(printingEndpoint)}
template={template}
editors={[CodeEditor]}
editors={editors}
previewAreas={[PdfPreview]}
/>
</Stack>