2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-31 21:25:42 +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:
Oliver
2025-10-31 09:41:32 +11:00
committed by GitHub
parent 8d1f7f39b4
commit 16a753bf59
9 changed files with 168 additions and 21 deletions

View File

@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for webauthn login for the frontend in [#9729](https://github.com/inventree/InvenTree/pull/9729)
- Added support for Debian 12, Ubuntu 22.04 and Ubuntu 24.04 in the installer and package in [#10705](https://github.com/inventree/InvenTree/pull/10705)
- Support for S3 and SFTP storage backends for media and static files ([#10140](https://github.com/inventree/InvenTree/pull/10140))
- Adds hooks for custom UI spotlight actions in [#10720](https://github.com/inventree/InvenTree/pull/10720)
### Changed

View File

@@ -69,6 +69,20 @@ For example:
The following user interface feature types are available:
### Spotlight Actions
Inject custom actions into the InvenTree "spotlight" search functionality by implementing the `get_ui_spotlight_actions` method:
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_spotlight_actions
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
extra:
show_source: True
summary: False
members: []
### Dashboard Items
The InvenTree dashboard is a collection of "items" which are displayed on the main dashboard page. Custom dashboard items can be added to the dashboard by implementing the `get_ui_dashboard_items` method:

View File

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

View File

@@ -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 = []

View File

@@ -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);
}

View File

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

View File

@@ -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);
}
});
}

View File

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

View File

@@ -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');