2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

UI plugins custom features (#8137)

* initial implementation to let plugins provide custom ui features

* provide exportable types

* refactor ref into renderContext to make it more generic and support template preview area ui plugins

* rename 'renderContext' -> 'featureContext' as not all features may render something

* allow to specify the function name via the source file string divided by a colon

* Bump api version

* add tests

* add docs

* add docs

* debug: workflow

* debug: workflow

* fix tests

* fix tests hopefully

* apply suggestions from codereview

* trigger: ci

* Prove that coverage does not work

* Revert "Prove that coverage does not work"

This reverts commit 920c58ea6f.

* potentially fix test???
This commit is contained in:
Lukas
2024-09-26 11:59:37 +02:00
committed by GitHub
parent 4d48a10bdd
commit 35362347a7
24 changed files with 974 additions and 265 deletions

View File

@ -52,7 +52,7 @@ export type Editor = {
};
type PreviewAreaProps = {};
type PreviewAreaRef = {
export type PreviewAreaRef = {
updatePreview: (
code: string,
previewItem: string,
@ -300,6 +300,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
<Tabs
value={previewValue}
onChange={setPreviewValue}
keepMounted={false}
style={{
minWidth: '200px',
display: 'flex',

View File

@ -1,18 +1,26 @@
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 { ModelType } from '../../enums/ModelType';
import { SettingsStateProps } from '../../states/SettingsState';
import { UserStateProps } from '../../states/UserState';
import { api } from '../../App';
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,
* for rendering an element in the user interface.
*
* @param model - The model type for the plugin (e.g. 'part' / 'purchaseorder')
* @param id - The ID (primary key) of the model instance for the plugin
* @param instance - The model instance data (if available)
* @param api - The Axios API instance (see ../states/ApiState.tsx)
* @param user - The current user instance (see ../states/UserState.tsx)
* @param userSettings - The current user settings (see ../states/SettingsState.tsx)
@ -21,10 +29,7 @@ import { UserStateProps } from '../../states/UserState';
* @param theme - The current Mantine theme
* @param colorScheme - The current Mantine color scheme (e.g. 'light' / 'dark')
*/
export type PluginContext = {
model?: ModelType | string;
id?: string | number | null;
instance?: any;
export type InvenTreeContext = {
api: AxiosInstance;
user: UserStateProps;
userSettings: SettingsStateProps;
@ -34,3 +39,37 @@ export type PluginContext = {
theme: MantineTheme;
colorScheme: MantineColorScheme;
};
export const useInvenTreeContext = () => {
const host = useLocalState((s) => s.host);
const navigate = useNavigate();
const user = useUserState();
const { colorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
const contextData = useMemo<InvenTreeContext>(() => {
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

@ -3,7 +3,7 @@ import { Alert, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { PluginContext } from './PluginContext';
import { InvenTreeContext } from './PluginContext';
import { findExternalPluginFunction } from './PluginSource';
// Definition of the plugin panel properties, provided by the server API
@ -21,7 +21,7 @@ export async function isPluginPanelHidden({
pluginContext
}: {
pluginProps: PluginPanelProps;
pluginContext: PluginContext;
pluginContext: InvenTreeContext;
}): Promise<boolean> {
if (!pluginProps.source) {
// No custom source supplied - panel is not hidden
@ -66,7 +66,7 @@ export default function PluginPanelContent({
pluginContext
}: Readonly<{
pluginProps: PluginPanelProps;
pluginContext: PluginContext;
pluginContext: InvenTreeContext;
}>): ReactNode {
const ref = useRef<HTMLDivElement>();

View File

@ -36,7 +36,13 @@ export async function loadExternalPluginSource(source: string) {
export async function findExternalPluginFunction(
source: string,
functionName: string
) {
): Promise<Function | null> {
// The source URL may also include the function name divided by a colon
// otherwise the provided function name will be used
if (source.includes(':')) {
[source, functionName] = source.split(':');
}
const module = await loadExternalPluginSource(source);
if (module && module[functionName]) {

View File

@ -0,0 +1,131 @@
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,
PreviewAreaComponent,
PreviewAreaRef
} from '../editors/TemplateEditor/TemplateEditor';
import {
PluginUIFuncWithoutInvenTreeContextType,
TemplateEditorUIFeature,
TemplatePreviewUIFeature
} from './PluginUIFeatureTypes';
export const getPluginTemplateEditor = (
func: PluginUIFuncWithoutInvenTreeContextType<TemplateEditorUIFeature>,
template: TemplateI
) =>
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({
ref: elRef.current!,
registerHandlers: ({ getCode, setCode }) => {
setCodeRef.current = setCode;
getCodeRef.current = getCode;
if (initialCodeRef.current) {
setCode(initialCodeRef.current);
}
},
template
});
} 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 Editor`}
icon={<IconExclamationCircle />}
>
<Text>{error}</Text>
</Alert>
)}
<div ref={elRef as any} style={{ display: 'flex', flex: 1 }}></div>
</Stack>
);
}) as EditorComponent;
export const getPluginTemplatePreview = (
func: PluginUIFuncWithoutInvenTreeContextType<TemplatePreviewUIFeature>,
template: TemplateI
) =>
forwardRef((props, ref) => {
const elRef = useRef<HTMLDivElement>();
const [error, setError] = useState<string | undefined>(undefined);
const updatePreviewRef = useRef<PreviewAreaRef['updatePreview']>();
useImperativeHandle(ref, () => ({
updatePreview: (...args) => updatePreviewRef.current?.(...args)
}));
useEffect(() => {
(async () => {
try {
await func({
ref: elRef.current!,
registerHandlers: ({ updatePreview }) => {
updatePreviewRef.current = updatePreview;
},
template
});
} catch (error) {
setError(t`Error occurred while rendering the template preview.`);
console.error(error);
}
})();
}, []);
return (
<Stack gap="xs" style={{ display: 'flex', flex: 1 }}>
{error && (
<Alert
color="red"
title={t`Error Loading Plugin Preview`}
icon={<IconExclamationCircle />}
>
<Text>{error}</Text>
</Alert>
)}
<div ref={elRef as any} style={{ display: 'flex', flex: 1 }}></div>
</Stack>
);
}) as PreviewAreaComponent;

View File

@ -0,0 +1,78 @@
import { ModelType } from '../../enums/ModelType';
import { InvenTreeIconType } from '../../functions/icons';
import { TemplateI } from '../../tables/settings/TemplateTable';
import { TemplateEditorProps } from '../editors/TemplateEditor/TemplateEditor';
import { InvenTreeContext } from './PluginContext';
// #region Type Helpers
export type BaseUIFeature = {
featureType: string;
requestContext: Record<string, any>;
responseOptions: Record<string, any>;
featureContext: Record<string, any>;
featureReturnType: any;
};
export type PluginUIGetFeatureType<T extends BaseUIFeature> = (params: {
featureContext: T['featureContext'];
inventreeContext: InvenTreeContext;
}) => T['featureReturnType'];
export type PluginUIFuncWithoutInvenTreeContextType<T extends BaseUIFeature> = (
featureContext: T['featureContext']
) => T['featureReturnType'];
export type PluginUIFeatureAPIResponse<T extends BaseUIFeature> = {
feature_type: T['featureType'];
options: T['responseOptions'];
source: string;
};
// #region Types
export type TemplateEditorUIFeature = {
featureType: 'template_editor';
requestContext: {
template_type: ModelType.labeltemplate | ModelType.reporttemplate;
template_model: ModelType;
};
responseOptions: {
key: string;
title: string;
icon: InvenTreeIconType;
};
featureContext: {
ref: HTMLDivElement;
registerHandlers: (handlers: {
setCode: (code: string) => void;
getCode: () => string;
}) => void;
template: TemplateI;
};
featureReturnType: void;
};
export type TemplatePreviewUIFeature = {
featureType: 'template_preview';
requestContext: {
template_type: ModelType.labeltemplate | ModelType.reporttemplate;
template_model: ModelType;
};
responseOptions: {
key: string;
title: string;
icon: InvenTreeIconType;
};
featureContext: {
ref: HTMLDivElement;
template: TemplateI;
registerHandlers: (handlers: {
updatePreview: (
code: string,
previewItem: string,
saveTemplate: boolean,
templateEditorProps: TemplateEditorProps
) => void | Promise<void>;
}) => void;
};
featureReturnType: void;
};

View File

@ -191,6 +191,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 {
InvenTreeContext,
useInvenTreeContext
} from '../components/plugins/PluginContext';
import PluginPanelContent, {
PluginPanelProps,
isPluginPanelHidden
@ -15,12 +16,18 @@ 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';
/**
* @param model - The model type for the plugin (e.g. 'part' / 'purchaseorder')
* @param id - The ID (primary key) of the model instance for the plugin
* @param instance - The model instance data (if available)
*/
export type PluginPanelContext = InvenTreeContext & {
model?: ModelType | string;
id?: string | number | null;
instance?: any;
};
export function usePluginPanels({
instance,
@ -31,13 +38,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 +70,15 @@ export function usePluginPanels({
});
// Cache the context data which is delivered to the plugins
const contextData: PluginContext = useMemo(() => {
const inventreeContext = useInvenTreeContext();
const contextData = useMemo<PluginPanelContext>(() => {
return {
model: model,
id: id,
instance: instance,
user: user,
host: host,
api: api,
navigate: navigate,
globalSettings: globalSettings,
userSettings: userSettings,
theme: theme,
colorScheme: colorScheme
...inventreeContext
};
}, [
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,90 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from '../App';
import { useInvenTreeContext } from '../components/plugins/PluginContext';
import { findExternalPluginFunction } from '../components/plugins/PluginSource';
import {
BaseUIFeature,
PluginUIFeatureAPIResponse,
PluginUIFuncWithoutInvenTreeContextType
} from '../components/plugins/PluginUIFeatureTypes';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
export function usePluginUIFeature<UIFeatureT extends BaseUIFeature>({
enabled = true,
featureType,
context
}: {
enabled?: boolean;
featureType: UIFeatureT['featureType'];
context: UIFeatureT['requestContext'];
}) {
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<
PluginUIFeatureAPIResponse<UIFeatureT>[]
>({
enabled: pluginUiFeaturesEnabled && !!featureType && enabled,
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 inventreeContext = useInvenTreeContext();
return useMemo<
{
options: UIFeatureT['responseOptions'];
func: PluginUIFuncWithoutInvenTreeContextType<UIFeatureT>;
}[]
>(() => {
return (
pluginData?.map((feature) => ({
options: feature.options,
func: (async (featureContext) => {
const func = await findExternalPluginFunction(
feature.source,
'getFeature'
);
if (!func) return;
return func({
featureContext,
inventreeContext
});
}) as PluginUIFuncWithoutInvenTreeContextType<UIFeatureT>
})) || []
);
}, [pluginData, inventreeContext]);
}

View File

@ -10,11 +10,24 @@ import {
PdfPreview,
TemplateEditor
} from '../../components/editors/TemplateEditor';
import {
Editor,
PreviewArea
} 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 {
getPluginTemplateEditor,
getPluginTemplatePreview
} from '../../components/plugins/PluginUIFeature';
import {
TemplateEditorUIFeature,
TemplatePreviewUIFeature
} from '../../components/plugins/PluginUIFeatureTypes';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { GetIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { useFilters } from '../../hooks/UseFilter';
import {
@ -23,6 +36,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,7 +63,7 @@ export type TemplateI = {
};
export interface TemplateProps {
modelType: ModelType;
modelType: ModelType.labeltemplate | ModelType.reporttemplate;
templateEndpoint: ApiEndpoints;
printingEndpoint: ApiEndpoints;
additionalFormFields?: ApiFormFieldSet;
@ -62,7 +76,7 @@ export function TemplateDrawer({
id: string | number;
templateProps: TemplateProps;
}>) {
const { templateEndpoint, printingEndpoint } = templateProps;
const { modelType, templateEndpoint, printingEndpoint } = templateProps;
const {
instance: template,
@ -74,6 +88,62 @@ export function TemplateDrawer({
throwError: true
});
// Editors
const extraEditors = usePluginUIFeature<TemplateEditorUIFeature>({
enabled: template?.model_type !== undefined,
featureType: 'template_editor',
context: { template_type: modelType, template_model: template?.model_type! }
});
const editors = useMemo(() => {
const editors = [CodeEditor];
if (!template) {
return editors;
}
editors.push(
...(extraEditors?.map(
(editor) =>
({
key: editor.options.key,
name: editor.options.title,
icon: GetIcon(editor.options.icon),
component: getPluginTemplateEditor(editor.func, template)
} as Editor)
) || [])
);
return editors;
}, [extraEditors, template]);
// Previews
const extraPreviews = usePluginUIFeature<TemplatePreviewUIFeature>({
enabled: template?.model_type !== undefined,
featureType: 'template_preview',
context: { template_type: modelType, template_model: template?.model_type! }
});
const previews = useMemo(() => {
const previews = [PdfPreview];
if (!template) {
return previews;
}
previews.push(
...(extraPreviews?.map(
(preview) =>
({
key: preview.options.key,
name: preview.options.title,
icon: GetIcon(preview.options.icon),
component: getPluginTemplatePreview(preview.func, template)
} as PreviewArea)
) || [])
);
return previews;
}, [extraPreviews, template]);
if (isFetching) {
return <LoadingOverlay visible={true} />;
}
@ -100,8 +170,8 @@ export function TemplateDrawer({
templateUrl={apiUrl(templateEndpoint, id)}
printingUrl={apiUrl(printingEndpoint)}
template={template}
editors={[CodeEditor]}
previewAreas={[PdfPreview]}
editors={editors}
previewAreas={previews}
/>
</Stack>
);

View File

@ -1,6 +1,7 @@
import { test } from './baseFixtures.js';
import { expect, test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
import { setPluginState } from './settings.js';
/*
* Test for label printing.
@ -81,8 +82,16 @@ test('PUI - Report Printing', async ({ page }) => {
await page.context().close();
});
test('PUI - Report Editing', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree');
test('PUI - Report Editing', async ({ page, request }) => {
const [username, password] = ['admin', 'inventree'];
await doQuickLogin(page, username, password);
// activate the sample plugin for this test
await setPluginState({
request,
plugin: 'sampleui',
state: true
});
// Navigate to the admin center
await page.getByRole('button', { name: 'admin' }).click();
@ -104,5 +113,38 @@ test('PUI - Report Editing', async ({ page }) => {
await page.getByText('The preview has been updated').waitFor();
await page.context().close();
// Test plugin provided editors
await page.getByRole('tab', { name: 'Sample Template Editor' }).click();
const textarea = page.locator('#sample-template-editor-textarea');
const textareaValue = await textarea.inputValue();
expect(textareaValue).toContain(
`<img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>`
);
textarea.fill(textareaValue + '\nHello world');
// Switch back and forth to see if the changed contents get correctly passed between the hooks
await page.getByRole('tab', { name: 'Code', exact: true }).click();
await page.getByRole('tab', { name: 'Sample Template Editor' }).click();
const newTextareaValue = await page
.locator('#sample-template-editor-textarea')
.inputValue();
expect(newTextareaValue).toMatch(/\nHello world$/);
// Test plugin provided previews
await page.getByRole('tab', { name: 'Sample Template Preview' }).click();
await page.getByRole('heading', { name: 'Hello world' }).waitFor();
const consoleLogPromise = page.waitForEvent('console');
await page
.getByLabel('split-button-preview-options', { exact: true })
.click();
const msg = (await consoleLogPromise).args();
expect(await msg[0].jsonValue()).toBe('updatePreview');
expect((await msg[1].jsonValue())[0]).toBe(newTextareaValue);
// deactivate the sample plugin again after the test
await setPluginState({
request,
plugin: 'sampleui',
state: false
});
});