2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +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

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