mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-02 14:15:45 +00:00
[UI] Plugin actions (#10720)
* Add backend code for custom actions * docs * Add sample action code * Fetch plugin features * Load plugins and call function * Support icons * Alert message * Update CHANGELOG.md * Rename action type * Update docs * pdated playwright tests
This commit is contained in:
@@ -15,6 +15,7 @@ logger = structlog.get_logger('inventree')
|
||||
|
||||
# List of supported feature types
|
||||
FeatureType = Literal[
|
||||
'spotlight_action', # Custom actions for the spotlight search
|
||||
'dashboard', # Custom dashboard items
|
||||
'panel', # Custom panels
|
||||
'template_editor', # Custom template editor
|
||||
@@ -99,11 +100,12 @@ class UserInterfaceMixin:
|
||||
|
||||
"""
|
||||
feature_map = {
|
||||
'spotlight_action': self.get_ui_spotlight_actions,
|
||||
'dashboard': self.get_ui_dashboard_items,
|
||||
'navigation': self.get_ui_navigation_items,
|
||||
'panel': self.get_ui_panels,
|
||||
'template_editor': self.get_ui_template_editors,
|
||||
'template_preview': self.get_ui_template_previews,
|
||||
'navigation': self.get_ui_navigation_items,
|
||||
}
|
||||
|
||||
if feature_type in feature_map:
|
||||
@@ -112,6 +114,21 @@ class UserInterfaceMixin:
|
||||
logger.warning(f'Invalid feature type: {feature_type}')
|
||||
return []
|
||||
|
||||
def get_ui_spotlight_actions(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
"""Return a list of custom actions to be injected into the UI spotlight.
|
||||
|
||||
Args:
|
||||
request: HTTPRequest object (including user information)
|
||||
context: Additional context data provided by the UI (query parameters)
|
||||
|
||||
Returns:
|
||||
list: A list of custom actions to be injected into the UI spotlight.
|
||||
"""
|
||||
# Default implementation returns an empty list
|
||||
return []
|
||||
|
||||
def get_ui_panels(
|
||||
self, request: Request, context: dict, **kwargs
|
||||
) -> list[UIFeature]:
|
||||
|
||||
@@ -49,6 +49,20 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
},
|
||||
}
|
||||
|
||||
def get_ui_spotlight_actions(self, request, context, **kwargs):
|
||||
"""Return a list of custom actions to be injected into the UI spotlight."""
|
||||
return [
|
||||
{
|
||||
'key': 'sample-action',
|
||||
'title': 'Sample Action',
|
||||
'description': 'This is a sample action for the spotlight search',
|
||||
'icon': 'ti:search:outline',
|
||||
'source': self.plugin_static_file(
|
||||
'sample_action.js:performSampleAction'
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
def get_ui_panels(self, request, context, **kwargs):
|
||||
"""Return a list of custom panels to be injected into the UI."""
|
||||
panels = []
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* A sample action plugin for InvenTree.
|
||||
*
|
||||
* This is a very basic example of how to define a custom action.
|
||||
* In practice, you would want to implement more complex logic here.
|
||||
*/
|
||||
|
||||
export function performSampleAction(data) {
|
||||
// Simply log the data to the console
|
||||
alert("Sample! Refer to the console");
|
||||
console.log("Sample action performed with data:", data);
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Container, Flex, Space } from '@mantine/core';
|
||||
import { Spotlight, createSpotlight } from '@mantine/spotlight';
|
||||
import {
|
||||
Spotlight,
|
||||
type SpotlightActionData,
|
||||
createSpotlight
|
||||
} from '@mantine/spotlight';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { type JSX, useEffect, useState } from 'react';
|
||||
import { type JSX, useEffect, useMemo, useState } from 'react';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { identifierString } from '@lib/functions/Conversion';
|
||||
import { ApiEndpoints, apiUrl } from '@lib/index';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../App';
|
||||
import { getActions } from '../../defaults/actions';
|
||||
import * as classes from '../../main.css';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { Boundary } from '../Boundary';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { useInvenTreeContext } from '../plugins/PluginContext';
|
||||
import { callExternalPluginFunction } from '../plugins/PluginSource';
|
||||
import {
|
||||
type PluginUIFeature,
|
||||
PluginUIFeatureType
|
||||
} from '../plugins/PluginUIFeature';
|
||||
import { Footer } from './Footer';
|
||||
import { Header } from './Header';
|
||||
|
||||
@@ -38,25 +56,65 @@ export const [firstStore, firstSpotlight] = createSpotlight();
|
||||
export default function LayoutComponent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useUserState();
|
||||
const userSettings = useUserSettingsState();
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const pluginsEnabled: boolean = useMemo(
|
||||
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||
[globalSettings]
|
||||
);
|
||||
|
||||
const inventreeContext = useInvenTreeContext();
|
||||
|
||||
const defaultActions = getActions(navigate);
|
||||
const [actions, setActions] = useState(defaultActions);
|
||||
const [customActions, setCustomActions] = useState<boolean>(false);
|
||||
|
||||
function actionsAreChanging(change: []) {
|
||||
if (change.length > defaultActions.length) setCustomActions(true);
|
||||
setActions(change);
|
||||
}
|
||||
// firstStore.subscribe(actionsAreChanging);
|
||||
const pluginActionsQuery = useQuery({
|
||||
enabled: pluginsEnabled,
|
||||
queryKey: ['plugin-actions', pluginsEnabled, user],
|
||||
refetchOnMount: true,
|
||||
queryFn: async () => {
|
||||
if (!pluginsEnabled) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// clear additional actions on location change
|
||||
useEffect(() => {
|
||||
if (customActions) {
|
||||
setActions(defaultActions);
|
||||
setCustomActions(false);
|
||||
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
|
||||
feature_type: PluginUIFeatureType.spotlight_action
|
||||
});
|
||||
|
||||
return api.get(url).then((response: any) => response.data);
|
||||
}
|
||||
}, [customActions, defaultActions, location]);
|
||||
});
|
||||
|
||||
const pluginActions: SpotlightActionData[] = useMemo(() => {
|
||||
return (
|
||||
pluginActionsQuery?.data?.map((item: PluginUIFeature) => {
|
||||
const pluginContext = {
|
||||
...inventreeContext,
|
||||
context: item.context
|
||||
};
|
||||
|
||||
return {
|
||||
id: identifierString(`a-${item.plugin_name}-${item.key}`),
|
||||
label: item.title,
|
||||
description: item.description,
|
||||
leftSection: item.icon && <ApiIcon name={item.icon} />,
|
||||
onClick: () => {
|
||||
callExternalPluginFunction(
|
||||
item.source,
|
||||
'executeAction',
|
||||
pluginContext
|
||||
);
|
||||
}
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [pluginActionsQuery?.data, inventreeContext]);
|
||||
|
||||
useEffect(() => {
|
||||
setActions([...defaultActions, ...pluginActions]);
|
||||
}, [defaultActions.length, pluginActions.length, location]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InvenTreePluginContext } from '@lib/types/Plugins';
|
||||
import { generateUrl } from '../../functions/urls';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
|
||||
@@ -56,3 +57,16 @@ export async function findExternalPluginFunction(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attempt to call an external plugin function, given the source URL and function name
|
||||
export async function callExternalPluginFunction(
|
||||
source: string,
|
||||
functionName: string,
|
||||
context: InvenTreePluginContext
|
||||
): Promise<any> {
|
||||
findExternalPluginFunction(source, functionName).then((func) => {
|
||||
if (func) {
|
||||
return func(context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
* Enumeration for available plugin UI feature types.
|
||||
*/
|
||||
export enum PluginUIFeatureType {
|
||||
spotlight_action = 'spotlight_action',
|
||||
dashboard = 'dashboard',
|
||||
panel = 'panel',
|
||||
template_editor = 'template_editor',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { systemKey, test } from './baseFixtures.js';
|
||||
import { doCachedLogin } from './login.js';
|
||||
import { setPluginState } from './settings.js';
|
||||
|
||||
test('Modals - Admin', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
@@ -52,7 +53,10 @@ test('Modals - Admin', async ({ browser }) => {
|
||||
await page.getByRole('cell', { name: 'InvenTree Version' }).click();
|
||||
});
|
||||
|
||||
test('Quick Command', async ({ browser }) => {
|
||||
test('Spotlight - Check Actions', async ({ browser }) => {
|
||||
// Enable the UI sample plugin
|
||||
await setPluginState({ plugin: 'sampleui', state: true });
|
||||
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -60,12 +64,24 @@ test('Quick Command', async ({ browser }) => {
|
||||
// Open Spotlight with Keyboard Shortcut and Search
|
||||
await page.locator('body').press(`${systemKey}+k`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByPlaceholder('Search...').fill('Dashboard');
|
||||
await page.getByPlaceholder('Search...').press('Tab');
|
||||
await page.getByPlaceholder('Search...').press('Enter');
|
||||
await page.getByRole('textbox', { name: 'Search...' }).fill('Dashboard');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard Go to the InvenTree dashboard' })
|
||||
.waitFor();
|
||||
|
||||
// User settings
|
||||
await page.getByRole('textbox', { name: 'Search...' }).fill('settings');
|
||||
await page
|
||||
.getByRole('button', { name: 'User Settings Go to your user' })
|
||||
.waitFor();
|
||||
|
||||
// Plugin generated action
|
||||
await page.getByRole('textbox', { name: 'Search...' }).fill('sample');
|
||||
await page.getByRole('button', { name: 'This is a sample action' }).waitFor();
|
||||
});
|
||||
|
||||
test('Quick Command - No Keys', async ({ browser }) => {
|
||||
test('Spotlight - No Keys', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
Reference in New Issue
Block a user