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:
@ -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',
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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>();
|
||||
|
||||
|
@ -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]) {
|
||||
|
131
src/frontend/src/components/plugins/PluginUIFeature.tsx
Normal file
131
src/frontend/src/components/plugins/PluginUIFeature.tsx
Normal 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;
|
78
src/frontend/src/components/plugins/PluginUIFeatureTypes.ts
Normal file
78
src/frontend/src/components/plugins/PluginUIFeatureTypes.ts
Normal 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;
|
||||
};
|
@ -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/',
|
||||
|
@ -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
|
||||
|
90
src/frontend/src/hooks/UsePluginUIFeature.tsx
Normal file
90
src/frontend/src/hooks/UsePluginUIFeature.tsx
Normal 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]);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
});
|
||||
});
|
||||
|
Reference in New Issue
Block a user