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