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:
@ -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
|
||||
|
@ -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'),
|
||||
|
0
src/backend/InvenTree/plugin/base/ui/__init__.py
Normal file
0
src/backend/InvenTree/plugin/base/ui/__init__.py
Normal file
90
src/backend/InvenTree/plugin/base/ui/api.py
Normal file
90
src/backend/InvenTree/plugin/base/ui/api.py
Normal 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',
|
||||
),
|
||||
]
|
@ -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 []
|
68
src/backend/InvenTree/plugin/base/ui/serializers.py
Normal file
68
src/backend/InvenTree/plugin/base/ui/serializers.py
Normal 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
|
||||
)
|
@ -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',
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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;
|
||||
};
|
||||
|
99
src/frontend/src/components/plugins/PluginUIFeature.tsx
Normal file
99
src/frontend/src/components/plugins/PluginUIFeature.tsx
Normal 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;
|
@ -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/',
|
||||
|
@ -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
|
||||
|
83
src/frontend/src/hooks/UsePluginUIFeature.tsx
Normal file
83
src/frontend/src/hooks/UsePluginUIFeature.tsx
Normal 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]);
|
||||
}
|
@ -6,6 +6,7 @@ export default function LabelTemplatePanel() {
|
||||
return (
|
||||
<TemplateTable
|
||||
templateProps={{
|
||||
templateType: 'label',
|
||||
modelType: ModelType.labeltemplate,
|
||||
templateEndpoint: ApiEndpoints.label_list,
|
||||
printingEndpoint: ApiEndpoints.label_print,
|
||||
|
@ -9,6 +9,7 @@ export default function ReportTemplateTable() {
|
||||
return (
|
||||
<TemplateTable
|
||||
templateProps={{
|
||||
templateType: 'report',
|
||||
modelType: ModelType.reporttemplate,
|
||||
templateEndpoint: ApiEndpoints.report_list,
|
||||
printingEndpoint: ApiEndpoints.report_print,
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user