mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-01 05:35: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:
@@ -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 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)
|
- 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))
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ For example:
|
|||||||
|
|
||||||
The following user interface feature types are available:
|
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
|
### 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:
|
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:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ logger = structlog.get_logger('inventree')
|
|||||||
|
|
||||||
# List of supported feature types
|
# List of supported feature types
|
||||||
FeatureType = Literal[
|
FeatureType = Literal[
|
||||||
|
'spotlight_action', # Custom actions for the spotlight search
|
||||||
'dashboard', # Custom dashboard items
|
'dashboard', # Custom dashboard items
|
||||||
'panel', # Custom panels
|
'panel', # Custom panels
|
||||||
'template_editor', # Custom template editor
|
'template_editor', # Custom template editor
|
||||||
@@ -99,11 +100,12 @@ class UserInterfaceMixin:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
feature_map = {
|
feature_map = {
|
||||||
|
'spotlight_action': self.get_ui_spotlight_actions,
|
||||||
'dashboard': self.get_ui_dashboard_items,
|
'dashboard': self.get_ui_dashboard_items,
|
||||||
|
'navigation': self.get_ui_navigation_items,
|
||||||
'panel': self.get_ui_panels,
|
'panel': self.get_ui_panels,
|
||||||
'template_editor': self.get_ui_template_editors,
|
'template_editor': self.get_ui_template_editors,
|
||||||
'template_preview': self.get_ui_template_previews,
|
'template_preview': self.get_ui_template_previews,
|
||||||
'navigation': self.get_ui_navigation_items,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if feature_type in feature_map:
|
if feature_type in feature_map:
|
||||||
@@ -112,6 +114,21 @@ class UserInterfaceMixin:
|
|||||||
logger.warning(f'Invalid feature type: {feature_type}')
|
logger.warning(f'Invalid feature type: {feature_type}')
|
||||||
return []
|
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(
|
def get_ui_panels(
|
||||||
self, request: Request, context: dict, **kwargs
|
self, request: Request, context: dict, **kwargs
|
||||||
) -> list[UIFeature]:
|
) -> 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):
|
def get_ui_panels(self, request, context, **kwargs):
|
||||||
"""Return a list of custom panels to be injected into the UI."""
|
"""Return a list of custom panels to be injected into the UI."""
|
||||||
panels = []
|
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 { t } from '@lingui/core/macro';
|
||||||
import { Container, Flex, Space } from '@mantine/core';
|
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 { 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 { 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 { getActions } from '../../defaults/actions';
|
||||||
import * as classes from '../../main.css';
|
import * as classes from '../../main.css';
|
||||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
import {
|
||||||
|
useGlobalSettingsState,
|
||||||
|
useUserSettingsState
|
||||||
|
} from '../../states/SettingsStates';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { Boundary } from '../Boundary';
|
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 { Footer } from './Footer';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
|
|
||||||
@@ -38,25 +56,65 @@ export const [firstStore, firstSpotlight] = createSpotlight();
|
|||||||
export default function LayoutComponent() {
|
export default function LayoutComponent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const user = useUserState();
|
||||||
const userSettings = useUserSettingsState();
|
const userSettings = useUserSettingsState();
|
||||||
|
const globalSettings = useGlobalSettingsState();
|
||||||
|
|
||||||
|
const pluginsEnabled: boolean = useMemo(
|
||||||
|
() => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'),
|
||||||
|
[globalSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inventreeContext = useInvenTreeContext();
|
||||||
|
|
||||||
const defaultActions = getActions(navigate);
|
const defaultActions = getActions(navigate);
|
||||||
const [actions, setActions] = useState(defaultActions);
|
const [actions, setActions] = useState(defaultActions);
|
||||||
const [customActions, setCustomActions] = useState<boolean>(false);
|
|
||||||
|
|
||||||
function actionsAreChanging(change: []) {
|
const pluginActionsQuery = useQuery({
|
||||||
if (change.length > defaultActions.length) setCustomActions(true);
|
enabled: pluginsEnabled,
|
||||||
setActions(change);
|
queryKey: ['plugin-actions', pluginsEnabled, user],
|
||||||
}
|
refetchOnMount: true,
|
||||||
// firstStore.subscribe(actionsAreChanging);
|
queryFn: async () => {
|
||||||
|
if (!pluginsEnabled) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
// clear additional actions on location change
|
const url = apiUrl(ApiEndpoints.plugin_ui_features_list, undefined, {
|
||||||
useEffect(() => {
|
feature_type: PluginUIFeatureType.spotlight_action
|
||||||
if (customActions) {
|
});
|
||||||
setActions(defaultActions);
|
|
||||||
setCustomActions(false);
|
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 (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { InvenTreePluginContext } from '@lib/types/Plugins';
|
||||||
import { generateUrl } from '../../functions/urls';
|
import { generateUrl } from '../../functions/urls';
|
||||||
import { useLocalState } from '../../states/LocalState';
|
import { useLocalState } from '../../states/LocalState';
|
||||||
|
|
||||||
@@ -56,3 +57,16 @@ export async function findExternalPluginFunction(
|
|||||||
|
|
||||||
return null;
|
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.
|
* Enumeration for available plugin UI feature types.
|
||||||
*/
|
*/
|
||||||
export enum PluginUIFeatureType {
|
export enum PluginUIFeatureType {
|
||||||
|
spotlight_action = 'spotlight_action',
|
||||||
dashboard = 'dashboard',
|
dashboard = 'dashboard',
|
||||||
panel = 'panel',
|
panel = 'panel',
|
||||||
template_editor = 'template_editor',
|
template_editor = 'template_editor',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { systemKey, test } from './baseFixtures.js';
|
import { systemKey, test } from './baseFixtures.js';
|
||||||
import { doCachedLogin } from './login.js';
|
import { doCachedLogin } from './login.js';
|
||||||
|
import { setPluginState } from './settings.js';
|
||||||
|
|
||||||
test('Modals - Admin', async ({ browser }) => {
|
test('Modals - Admin', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, {
|
const page = await doCachedLogin(browser, {
|
||||||
@@ -52,7 +53,10 @@ test('Modals - Admin', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'InvenTree Version' }).click();
|
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);
|
const page = await doCachedLogin(browser);
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
@@ -60,12 +64,24 @@ test('Quick Command', async ({ browser }) => {
|
|||||||
// Open Spotlight with Keyboard Shortcut and Search
|
// Open Spotlight with Keyboard Shortcut and Search
|
||||||
await page.locator('body').press(`${systemKey}+k`);
|
await page.locator('body').press(`${systemKey}+k`);
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await page.getByPlaceholder('Search...').fill('Dashboard');
|
await page.getByRole('textbox', { name: 'Search...' }).fill('Dashboard');
|
||||||
await page.getByPlaceholder('Search...').press('Tab');
|
|
||||||
await page.getByPlaceholder('Search...').press('Enter');
|
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);
|
const page = await doCachedLogin(browser);
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|||||||
Reference in New Issue
Block a user